From 1f1fe80968abc2f08d5502fd56b65acd023302f3 Mon Sep 17 00:00:00 2001 From: mindfn Date: Wed, 29 Jul 2026 22:25:54 +0800 Subject: [PATCH 01/15] feat(f257): shared types and lifecycle contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R6 split: A — shared types and segment lifecycle contracts used by runtime base and wiring. --- .../friction/friction-rollup-report.ts | 1 + packages/shared/src/index.ts | 2 + packages/shared/src/types/ball-custody.ts | 5 +- packages/shared/src/types/cat-routing.ts | 8 +- packages/shared/src/types/friction-signal.ts | 4 +- packages/shared/src/types/hook-override.ts | 86 ++++ packages/shared/src/types/index.ts | 33 ++ packages/shared/src/types/injection-trace.ts | 23 ++ packages/shared/src/types/prompt-hook.ts | 26 ++ .../shared/src/types/segment-lifecycle.ts | 378 ++++++++++++++++++ .../__tests__/segment-enablement.test.ts | 189 +++++++++ packages/shared/src/utils/index.ts | 1 + .../shared/src/utils/segment-enablement.ts | 200 +++++++++ packages/shared/vitest.config.js | 1 + 14 files changed, 952 insertions(+), 5 deletions(-) create mode 100644 packages/shared/src/types/hook-override.ts create mode 100644 packages/shared/src/types/segment-lifecycle.ts create mode 100644 packages/shared/src/utils/__tests__/segment-enablement.test.ts create mode 100644 packages/shared/src/utils/segment-enablement.ts diff --git a/packages/api/src/infrastructure/harness-eval/friction/friction-rollup-report.ts b/packages/api/src/infrastructure/harness-eval/friction/friction-rollup-report.ts index cede6b272f..d17e451156 100644 --- a/packages/api/src/infrastructure/harness-eval/friction/friction-rollup-report.ts +++ b/packages/api/src/infrastructure/harness-eval/friction/friction-rollup-report.ts @@ -39,6 +39,7 @@ const CHANNEL_SENSOR_FORM: Record = { cancel: 'act', // 中断动作 'user-feedback': 'reason', // 用户显式反馈 = 中断理由 'eval-domain': 'aggregate_proxy', // eval 域 metric = 聚合 proxy + 'guard-anomaly': 'reason', // F257 V2: 猫显式上报撞锅 = 中断理由(引用 ledgerId) }; /** Enrich a cluster with sensorForms (distinct, sorted) + max severity (surfaced for the eval cat). */ diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index bb97420dc6..6585f8d685 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -40,5 +40,7 @@ export * from './source-code-extensions.js'; export * from './text-utils.js'; // Export all types export * from './types/index.js'; +// F257 Console 判据⑥: segment enablement matrix (pure logic, safe for frontend) +export * from './utils/segment-enablement.js'; // Export subject key utilities (#320) export * from './utils/subject-key.js'; diff --git a/packages/shared/src/types/ball-custody.ts b/packages/shared/src/types/ball-custody.ts index b9791f9df2..2ddf94e55c 100644 --- a/packages/shared/src/types/ball-custody.ts +++ b/packages/shared/src/types/ball-custody.ts @@ -13,14 +13,15 @@ */ // --------------------------------------------------------------------------- -// Event kinds(全 17 种,每种在 state-machine 转移表必有一行——INV-10 穷举钉死) -// Phase B 13 种 + Phase C 3 安乐死 + Phase P 1 wakeWhen kind +// Event kinds(全 18 种,每种在 state-machine 转移表必有一行——INV-10 穷举钉死) +// Phase B 13 种 + Phase C 3 安乐死 + Phase P 1 wakeWhen kind + LI-005 1 void_ack // --------------------------------------------------------------------------- export type BallEventKind = | 'ball.handed' // 行首 @ 路由投递给某猫(payload: { fromCatId?, toCatId }) | 'ball.handed_cvo' // @co-creator(payload: { fromCatId?, intent: BallIntent }) | 'ball.void_pass' // F167 forced-pass guard / 路由守卫:说传了但无系统动作 + | 'ball.void_ack' // LI-005: A2A 接球但无持久触发器绑定(球静默死亡) | 'ball.held' // hold_ball 设(payload: { catId, fireAt }) | 'ball.hold_expired' // hold fireAt 已过 | 'invocation.started' // 持有者起 invocation diff --git a/packages/shared/src/types/cat-routing.ts b/packages/shared/src/types/cat-routing.ts index 8d6c3f78f2..1cf0e9663f 100644 --- a/packages/shared/src/types/cat-routing.ts +++ b/packages/shared/src/types/cat-routing.ts @@ -10,4 +10,10 @@ export interface CatAlternative { export type CatRoutingError = | { kind: 'cat_not_found'; mention: string; alternatives: CatAlternative[] } | { kind: 'cat_disabled'; catId: CatId; displayName: string; alternatives: CatAlternative[] } - | { kind: 'target_not_in_thread'; catId: CatId; threadId: string }; + | { kind: 'target_not_in_thread'; catId: CatId; threadId: string } + /** + * F257 #1 (dev-628ea4d1): the mention pattern is held by MORE THAN ONE cat. + * Routing refuses to guess — candidates carry each holder's unambiguous + * handle so the sender can retry with an explicit one. + */ + | { kind: 'mention_ambiguous'; mention: string; candidates: CatAlternative[] }; diff --git a/packages/shared/src/types/friction-signal.ts b/packages/shared/src/types/friction-signal.ts index fcadb443f1..2bd6135fd0 100644 --- a/packages/shared/src/types/friction-signal.ts +++ b/packages/shared/src/types/friction-signal.ts @@ -11,8 +11,8 @@ * domainId/sourceAdapter 注册枚举无关(Phase C 协调,勿混用)。 */ -/** 摩擦信号来源通道。Phase A 仅实现 'paw-feel',其余 Phase B 起补齐。 */ -export type FrictionChannel = 'paw-feel' | 'cancel' | 'user-feedback' | 'eval-domain'; +/** 摩擦信号来源通道。Phase A 仅实现 'paw-feel';F257 V2 增补 'guard-anomaly'(第 5 通道)。 */ +export type FrictionChannel = 'paw-feel' | 'cancel' | 'user-feedback' | 'eval-domain' | 'guard-anomaly'; /** 摩擦严重度。Phase A 采集层默认 'medium',severity 推断留 Phase B。 */ export type FrictionSeverity = 'low' | 'medium' | 'high'; diff --git a/packages/shared/src/types/hook-override.ts b/packages/shared/src/types/hook-override.ts new file mode 100644 index 0000000000..e25dab5d45 --- /dev/null +++ b/packages/shared/src/types/hook-override.ts @@ -0,0 +1,86 @@ +/** + * Hook Override Types — F237 PR3 + * + * Types for the HookOverrideStore: per-workspace runtime override layer + * for prompt hook enable/disable, content overrides, and change tracking. + * + * Design rationale (KD-12): changes happen in override layer (not base files), + * verified by eval, then evidence-backed baseline sedimentation. + */ + +// --------------------------------------------------------------------------- +// Override state +// --------------------------------------------------------------------------- + +/** Who created this override: operator (human) or auto-eval (system). */ +export type HookOverrideSource = 'operator' | 'auto-eval'; + +/** Per-hook override state. Stored in Redis HASH per workspace. */ +export interface HookOverride { + hookId: string; + /** Override enable/disable. undefined = use manifest baseline. */ + enabled?: boolean; + /** Who set the enabled field (field-level provenance, sol P1 fix). */ + enabledSource?: HookOverrideSource; + /** Override template content. undefined = use manifest template. */ + contentOverride?: string; + /** Override content version (incremented on each content change). */ + contentVersion?: number; + /** + * Active epoch version — stable monotonic ID (R7 fix). + * Propagated to trace pipeline for per-version eval grouping. + * Set by setContentOverride/activateVersion, cleared by rollback/clear. + */ + activeEpochVersion?: number; + /** Who set contentOverride (field-level provenance, sol P1 fix). */ + contentSource?: HookOverrideSource; + /** + * Last operation source. Retained for backward compat but NOT reliable + * for per-field provenance — use enabledSource/contentSource instead. + * Sol finding: enable() after setContentOverride() overwrites this field, + * corrupting provenance for limited-edit reconciliation. + */ + source: HookOverrideSource; + /** Last update timestamp (ms). */ + updatedAt: number; + /** catId or 'system' who made the change. */ + updatedBy: string; +} + +// --------------------------------------------------------------------------- +// Change event (ZSET time-indexed) +// --------------------------------------------------------------------------- + +/** Possible override actions, recorded as change events. */ +export type OverrideAction = 'enable' | 'disable' | 'content-set' | 'content-clear' | 'rollback' | 'version-activate'; + +/** Immutable record of an override change. TTL=0 (permanent, Iron Law 5). */ +export interface OverrideChangeEvent { + eventId: string; + hookId: string; + workspaceId: string; + action: OverrideAction; + source: HookOverrideSource; + timestamp: number; + actorId: string; + /** Why this change was made (audit trail). */ + reason?: string; + /** Content version at time of event (content-set only). Absent on legacy events. */ + contentVersion?: number; + /** + * Stable epoch version ID (P1-3 R6 fix). Monotonic, never resets. + * Maps 1:1 to chain VersionEpoch.version. Used for snapshot keys + * and version-activate target resolution. Absent on pre-R6 events. + */ + epochVersion?: number; +} + +// --------------------------------------------------------------------------- +// Sync snapshot (for pipeline hot-path) +// --------------------------------------------------------------------------- + +/** + * Pre-loaded override map for synchronous resolution in HookRegistry. + * Loaded async once, then used sync in the pipeline hot path. + */ +export type HookOverrideSnapshot = ReadonlyMap; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 8d88e91102..0df1761d93 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -473,6 +473,14 @@ export { isValidActionStatus, isValidScope, } from './game.js'; +// Hook override types (F237 PR3 — HookOverrideStore) +export type { + HookOverride, + HookOverrideSnapshot, + HookOverrideSource, + OverrideAction, + OverrideChangeEvent, +} from './hook-override.js'; // ID types export type { CatId, @@ -690,6 +698,7 @@ export type { HookManifest, HookResolver, HookStage, + HookVariableDef, PingPongInput, PromptPatch, RegisteredHook, @@ -744,6 +753,30 @@ export type { RichMessageExtra, } from './rich.js'; export { isValidRichBlock, normalizeRichBlock } from './rich.js'; +export type { + ActionableInfo, + ActiveStage, + EvalStageSummary, + GovernanceStageSummary, + GuardMetric, + LifecycleEvent, + LifecycleEventKind, + ProvenanceGapKind, + ReplayGuardEvent, + ReplayProvenanceGap, + ReplaySnapshot, + ReplaySurroundingMessage, + SegmentContentSourceKind, + SegmentLifecycleResponse, + SegmentReplayResponse, + SegmentVerdict, + TracingStageSummary, + VersionEpoch, + VersionEpochStatus, + VersionOrigin, +} from './segment-lifecycle.js'; +// F257 Phase D: Segment lifecycle chain types +export { SEGMENT_VERDICTS } from './segment-lifecycle.js'; // Session chain types (F24 Session Chain + Context Health) export type { ContextHealth, diff --git a/packages/shared/src/types/injection-trace.ts b/packages/shared/src/types/injection-trace.ts index abbd9a6b7d..4c37c940da 100644 --- a/packages/shared/src/types/injection-trace.ts +++ b/packages/shared/src/types/injection-trace.ts @@ -21,6 +21,29 @@ export interface ObservedSegment { charCount: number; /** Approximate token count (tiktoken cl100k_base). */ tokenEstimate: number; + + // F257 Phase A Line B: optional pipeline-rich fields (backward compatible). + // v0 collector leaves these undefined; pipeline bridge populates them. + /** Hook manifest version (fired events only). */ + version?: number; + /** Fine-grained pipeline status: 'fired' | 'skipped' | 'disabled' | 'observed'. */ + pipelineStatus?: string; + /** Skip reason code (skipped events only). */ + reasonCode?: string; + /** Human-readable skip reason (skipped events only). */ + reason?: string; + /** Who disabled the hook: 'manifest' | 'operator' | 'auto-eval' (disabled events only). */ + disabledBy?: string; + + // F257 Console 判据④:真现场回放 provenance(可选,旧数据兼容)。 + /** Actual rendered content at event time — only for fired/observed segments. */ + content?: string | null; + /** How the content was actually produced at event time. */ + contentSourceKind?: import('./segment-lifecycle.js').SegmentContentSourceKind; + /** Template source identifier (templateId or template path) for variable-segment provenance. */ + templateRef?: string | null; + /** Variable bindings snapshot at event time for variable-segment provenance. */ + templateVars?: Record | null; } /** diff --git a/packages/shared/src/types/prompt-hook.ts b/packages/shared/src/types/prompt-hook.ts index 6fb34f8664..0ec26758ff 100644 --- a/packages/shared/src/types/prompt-hook.ts +++ b/packages/shared/src/types/prompt-hook.ts @@ -20,6 +20,20 @@ export type SafetyTier = 'readonly' | 'limited-edit' | 'editable'; export type TransparencyTier = 'visible-by-default' | 'opt-in-view' | 'debug-only'; export type GovernanceTier = 'immutable' | 'human-gated' | 'auto-evolve'; +// --------------------------------------------------------------------------- +// Variable definition metadata (F257 Console 判据⑤) +// --------------------------------------------------------------------------- + +/** Canonical description of a single {{VAR}} placeholder used by a segment. */ +export interface HookVariableDef { + /** Variable name as it appears in the template (without braces). */ + name: string; + /** Human-readable explanation of what this variable carries. */ + description?: string; + /** Example value shown in the Console editor (not a default). */ + placeholder?: string; +} + // --------------------------------------------------------------------------- // HookManifest — parsed from hook.yaml // --------------------------------------------------------------------------- @@ -48,6 +62,10 @@ export interface HookManifest { /** AssemblerInput fields this hook reads */ inputs: string[]; + // -- Variable metadata (canonical source for Console editor) -- + /** Per-variable definitions for templates that use {{VAR}} placeholders. */ + variables?: HookVariableDef[]; + // -- Override constraints -- /** Whether runtime disable is allowed (false = immutable, e.g. S1/D8/L1-L7) */ disableable: boolean; @@ -96,6 +114,14 @@ export interface TraceEventFired extends TraceEventBase { version: number; contentHash: string; tokenEstimate: number; + /** F257 Console 判据④:actual rendered content at event time for true-scene replay. */ + content?: string; + /** F257 Console 判据④:how the content was actually produced at event time. */ + contentSourceKind?: import('./segment-lifecycle.js').SegmentContentSourceKind; + /** F257 Console 判据④:template source identifier for variable-segment provenance. */ + templateRef?: string | null; + /** F257 Console 判据④:variable bindings snapshot at event time. */ + templateVars?: Record | null; } export interface TraceEventSkipped extends TraceEventBase { diff --git a/packages/shared/src/types/segment-lifecycle.ts b/packages/shared/src/types/segment-lifecycle.ts new file mode 100644 index 0000000000..44b6b00240 --- /dev/null +++ b/packages/shared/src/types/segment-lifecycle.ts @@ -0,0 +1,378 @@ +/** + * Segment Lifecycle Types — F257 Phase D Enhancement + * + * Read-model types for the version lifecycle chain: + * v1 → tracing → eval → governance → v2 → tracing → ... + * + * The chain is assembled at query time from existing stores + * (InjectionTraceStore + HookOverrideStore + GuardRejectionEventLog). + * No new write-path — pure projection. + */ + +import type { SegmentEnablementMatrix } from '../utils/segment-enablement.js'; + +// --------------------------------------------------------------------------- +// Lifecycle event kinds +// --------------------------------------------------------------------------- + +/** + * Events that appear on the lifecycle chain. + * + * - auto-iterate: governance approve → natural version bump + * - user-create: user manually creates a new version + * - version-activate: user switches the active version + * - user-edit: user edits content of an existing version + * - eval-pass: eval judgment: keep / alive + * - eval-reject: eval judgment: needs attention + * - governance-approve: governance decision to approve + * - governance-reject: operator-initiated disable (AF-5: distinct from eval-reject) + */ +export type LifecycleEventKind = + | 'auto-iterate' + | 'user-create' + | 'version-activate' + | 'user-edit' + | 'eval-pass' + | 'eval-reject' + | 'governance-approve' + | 'governance-reject'; + +/** A single event on the lifecycle chain. */ +export interface LifecycleEvent { + eventId: string; + kind: LifecycleEventKind; + timestamp: number; + actorId: string; + /** Short human-readable detail (e.g. "v1 → v2", "switched to v1"). */ + detail: string; +} + +// --------------------------------------------------------------------------- +// Stage summaries within a version epoch +// --------------------------------------------------------------------------- + +/** Tracing stage summary: observation counts and time range. */ +export interface TracingStageSummary { + /** + * Observed rows in the CURRENT query window (all pipelineStatus, incl. + * observe-only). EXACT full-window aggregate (sol R6): the route scans all + * matching rows for counting — only the DETAIL row list is capped + * (see SegmentLifecycleResponse observationsCapped), never the counts. + */ + observationCount: number; + /** + * 判据② P1 (sol R5): producer-semantics fired count — same predicate as + * segment-judgment-engine isFired (pipelineStatus 'fired' or legacy missing). + * NEVER conflate with observationCount: observe-only rows are observations, + * not injections. EXACT (same full-window scan as observationCount). + */ + firedCount: number; + firstAt: number | null; + lastAt: number | null; +} + +/** + * 判据② P2 (sol R5): why a provenance field is null. + * 'legacy-missing' = pre-6c cache entry never had the field; + * 'invalid-present' = field was present but malformed (forgery-grade input, + * failed closed at the read seam). The UI must not mislabel one as the other. + */ +export type ProvenanceGapKind = 'legacy-missing' | 'invalid-present'; + +// --------------------------------------------------------------------------- +// Segment verdict vocabulary (judgment-schema-v1 §2, frozen) +// --------------------------------------------------------------------------- + +/** + * Canonical per-segment eval verdict vocabulary — single source of truth shared by + * the judgment engine (producer) and the Console (renderer). A new verdict fails + * closed at compile time (`satisfies Record` / exhaustive switch) + * instead of silently rendering with no explanation. + * + * Domain note: this is the SEGMENT verdict (judgment-schema-v1 §2). It is DISTINCT + * from the Eval Hub verdict-handoff vocabulary (fix | build | keep_observe | + * delete_sunset) — do not conflate the two. + */ +export const SEGMENT_VERDICTS = [ + 'alive', + 'dormant', + 'unmeasurable', + 'observability-debt', + 'needs-denominator', + 'retire-candidate', +] as const; +export type SegmentVerdict = (typeof SEGMENT_VERDICTS)[number]; + +/** Eval stage summary: latest judgment result or null if not yet evaluated. */ +export interface EvalStageSummary { + verdict: SegmentVerdict | null; + injectionCount: number; + violationCount: number; + evaluatedAt: number | null; + /** + * 判据② (F257 #6 slice 6c): the judgment's OWN eval sampling window + * [startMs, endMs) — NEVER the lifeline query window. `evaluatedAt` is a + * point in time, not a window substitute. + * null = legacy cached judgment without provenance (fail-visible "评估窗口未知"). + */ + evalWindow: { startMs: number; endMs: number } | null; + /** 判据② P2: why evalWindow is null (legacy vs corrupted). null when evalWindow is present. */ + evalWindowGap: ProvenanceGapKind | null; + /** + * 判据②: denominator semantics of injectionCount/violationCount. + * null = legacy cached judgment (fail-visible "分母未知"). + */ + denominatorKind: 'fired-count' | 'session-count' | 'none' | null; + /** 判据② P2: why denominatorKind is null (legacy vs corrupted). null when present. */ + denominatorGap: ProvenanceGapKind | null; +} + +/** Governance stage summary: decision state. */ +export interface GovernanceStageSummary { + decision: 'approved' | 'pending' | null; + decidedAt: number | null; + actorId: string | null; +} + +// --------------------------------------------------------------------------- +// Version epoch — one node in the chain +// --------------------------------------------------------------------------- + +/** + * The lifecycle status of a version epoch. + * + * idle: segment exists but has no trace data yet + * tracing: actively being observed (has observations) + * eval-pending: tracing complete, awaiting eval + * eval-pass: eval passed + * eval-reject: eval rejected → will re-enter tracing + * governance-pending: eval passed, awaiting governance + * governance-approved: governance approved → may produce next version + */ +export type VersionEpochStatus = + | 'idle' + | 'tracing' + | 'eval-pending' + | 'eval-pass' + | 'eval-reject' + | 'governance-pending' + | 'governance-approved'; + +/** How this version was created. */ +export type VersionOrigin = 'manifest' | 'auto-iterate' | 'user-create'; + +/** A single version epoch in the lifecycle chain. */ +export interface VersionEpoch { + version: number; + origin: VersionOrigin; + startedAt: number; + status: VersionEpochStatus; + isActive: boolean; + tracing: TracingStageSummary | null; + eval: EvalStageSummary | null; + governance: GovernanceStageSummary | null; + events: LifecycleEvent[]; +} + +// --------------------------------------------------------------------------- +// API response +// --------------------------------------------------------------------------- + +/** Per-guard event count attributed to an epoch via activation timeline. */ +export interface GuardMetric { + guardId: string; + count: number; +} + +// --------------------------------------------------------------------------- +// 判据① — activeStage / actionableStage (cycle read model, F257 #6 slice 6b) +// --------------------------------------------------------------------------- + +/** + * The REAL stage of the lifecycle loop for the ACTIVE version (判据①). + * + * The loop is NOT a one-way pipeline: an eval that cannot conclude + * (`unmeasurable` / `observability-debt` / `needs-denominator`) or rejects + * (`retire-candidate`) returns the cycle to `tracing`. Only a conclusive + * `alive` / `dormant` verdict parks the cycle at `governance` (informational — + * being AT governance implies no operator action by itself). + */ +export type ActiveStage = 'tracing' | 'governance'; + +/** + * Actionable derivation (判据①): a stage is actionable ONLY when real pending + * governance Candidates exist — never inferred from a synthesized + * `governance.decision === 'pending'` (that false signal caused the original + * incident: operator saw "pending" with no candidate to review). + * + * `source: 'unavailable'` is the honest provenance gap: the Candidate + * projection is not wired yet, so `candidateCount` is null (UNKNOWN, not 0) + * and the UI must say "cannot determine" instead of guessing. + */ +export interface ActionableInfo { + /** Stage awaiting an operator decision; null when 0 candidates or unknown. */ + stage: 'governance' | null; + /** Real pending Candidate count; null = candidate projection unavailable. */ + candidateCount: number | null; + /** Provenance of this derivation. */ + source: 'candidate-count' | 'unavailable'; +} + +/** A single raw observation returned alongside the lifecycle chain. */ +export interface SegmentObservation { + threadId: string; + turnId: string; + timestamp: number; + catId: string; + pipelineStatus: string; + version: number | null; + charCount: number; +} + +/** A single guard rejection event correlated to the query window. */ +export interface SegmentGuardEvent { + eventId: string; + kind: string; + threadId: string; + catId: string; + timestamp: number; + guardId: string; + /** Window-correlated, not causally linked. */ + attribution?: 'window-correlated'; +} + +/** Full lifecycle response for GET /api/segment-lifeline/:segmentId. */ +export interface SegmentLifecycleResponse { + segmentId: string; + segmentName: string; + activeVersion: number; + chain: VersionEpoch[]; + /** Backward-compat status summary. */ + currentStatus: 'idle' | 'tracing' | 'evaluated'; + /** 判据①: real loop stage of the active version (unmeasurable → tracing). */ + activeStage: ActiveStage; + /** 判据①: actionable only via real pending Candidates (honest gap when unwired). */ + actionable: ActionableInfo; + /** + * The CURRENT lifeline QUERY window [startMs, endMs) — used for tracing + * observations/guard events. 判据②: distinct coordinate from each epoch's + * `eval.evalWindow` (the judgment's OWN historical sampling window); the UI + * must label them separately, never as one context. + */ + window: { startMs: number; endMs: number }; + /** Raw observations in the query window (detail list, capped separately). */ + observations: SegmentObservation[]; + /** True when the detail list was truncated; aggregate counts remain exact. */ + observationsCapped?: boolean; + /** Guard events in the query window. */ + guardEvents: SegmentGuardEvent[]; + /** Current runtime override state (null = manifest baseline). */ + overrideState: { hookId: string; enabled: boolean; contentVersion: number | null } | null; + /** Guard events attributed to each epoch via activation timeline (R16). */ + epochGuardMetrics: Record; + /** F257 Console 判据⑥: unified enablement matrix for CTA states and blocked reasons. */ + enablementMatrix: SegmentEnablementMatrix; +} + +// --------------------------------------------------------------------------- +// 判据④ — Tracing 真现场回放 (F257 Console) +// --------------------------------------------------------------------------- + +/** Provenance gap taxonomy for replay fields. */ +export type ReplayProvenanceGap = 'legacy-missing' | 'invalid-present' | 'unavailable'; + +/** How the rendered segment content was actually produced at event time. */ +export type SegmentContentSourceKind = + | 'template' + | 'override' + | 'content-var' + | 'file-fallback' + | 'native-l0' + | 'aggregate' + | null; + +/** + * Durable, owner-scoped replay snapshot for F257 Console criterion ④. + * + * Separated from the compact InjectionTraceSummary so that summary stays + * small (counts/hashes/anchors) while replay retains event-time content and + * context. TTL=0 by default — user-visible recoverable data. + */ +export interface ReplaySnapshot { + segmentId: string; + threadId: string; + turnId: string; + timestamp: number; + catId: string; + stage: 'session-init' | 'per-turn'; + pipelineStatus: string; + version: number | null; + + // Content + source truth (P1-3) + content: string | null; + contentSourceKind: SegmentContentSourceKind; + contentSourceRef: string | null; + templateVars: Record | null; + + // Event-time conversation anchors (P1-1) + /** The incoming message this segment was injected for (user msg or A2A trigger). */ + messageAnchorId: string | null; + /** Message IDs of the surrounding context captured at event time. */ + surroundingMessageIds: string[]; + /** + * Structured completeness gap for the captured context. Persisted alongside the + * IDs so the replay route can honestly surface unavailable/legacy-missing context + * instead of faking a complete empty set. + */ + surroundingMessagesGap: ReplayProvenanceGap | null; + + // Ownership (P1-2) + ownerUserId: string; +} + +/** A single message in the surrounding conversation context. */ +export interface ReplaySurroundingMessage { + messageId: string; + role: 'user' | 'assistant' | 'system'; + catId?: string | null; + contentPreview: string; + timestamp: number; +} + +/** Guard event in the replay scene. */ +export interface ReplayGuardEvent { + eventId: string; + kind: string; + guardId: string; + catId: string; + timestamp: number; + /** Window-correlated, not causally linked. */ + attribution: 'window-correlated'; +} + +/** Full replay response for GET /api/segment-lifeline/:segmentId/replay. */ +export interface SegmentReplayResponse { + segmentId: string; + threadId: string; + turnId: string; + timestamp: number; + catId: string; + stage: 'session-init' | 'per-turn'; + pipelineStatus: string; + version: number | null; + versionGap: ReplayProvenanceGap | null; + content: string | null; + contentGap: ReplayProvenanceGap | null; + contentSourceKind: SegmentContentSourceKind; + contentSourceKindGap: ReplayProvenanceGap | null; + templateRef: string | null; + templateRefGap: ReplayProvenanceGap | null; + templateVars: Record | null; + templateVarsGap: ReplayProvenanceGap | null; + messageAnchorId: string | null; + messageAnchorIdGap: ReplayProvenanceGap | null; + surroundingMessages: ReplaySurroundingMessage[] | null; + surroundingMessagesGap: ReplayProvenanceGap | null; + guardEvents: ReplayGuardEvent[]; + guardEventsGap: ReplayProvenanceGap | null; +} diff --git a/packages/shared/src/utils/__tests__/segment-enablement.test.ts b/packages/shared/src/utils/__tests__/segment-enablement.test.ts new file mode 100644 index 0000000000..211bb3a5a0 --- /dev/null +++ b/packages/shared/src/utils/__tests__/segment-enablement.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from 'vitest'; +import { + type ResolveSegmentEnablementMatrixInput, + resolveSegmentEnablementMatrix, + type SegmentLocalOverlayAction, + type SegmentRuntimeOverrideAction, +} from '../segment-enablement.js'; + +const DEFAULT_INPUT: ResolveSegmentEnablementMatrixInput = { + segmentId: 'S1', + safetyTier: 'editable', + allowLocalOverride: true, + disableable: true, + localOverlay: { hasOverlay: false, hasBackup: false }, + runtimeOverride: { + enabled: true, + hasOverride: false, + hasContentOverride: false, + hasVersionSnapshot: false, + availableEpochVersions: [], + }, +}; + +const ALL_LOCAL_ACTIONS: SegmentLocalOverlayAction[] = ['edit', 'restoreBackup', 'reset']; +const ALL_RUNTIME_ACTIONS: SegmentRuntimeOverrideAction[] = ['disable', 'enable', 'rollback', 'activateVersion']; + +function allowedLocalActions(matrix: ReturnType): SegmentLocalOverlayAction[] { + return ALL_LOCAL_ACTIONS.filter((a) => matrix.localOverlay.actions[a].allowed); +} + +function allowedRuntimeActions( + matrix: ReturnType, +): SegmentRuntimeOverrideAction[] { + return ALL_RUNTIME_ACTIONS.filter((a) => matrix.runtimeOverride.actions[a].allowed); +} + +function localReasonCode(matrix: ReturnType, action: SegmentLocalOverlayAction) { + return matrix.localOverlay.actions[action].reasonCode; +} + +function runtimeReasonCode( + matrix: ReturnType, + action: SegmentRuntimeOverrideAction, +) { + return matrix.runtimeOverride.actions[action].reasonCode; +} + +describe('resolveSegmentEnablementMatrix', () => { + it('editable + allowLocalOverride + disableable + enabled baseline', () => { + const m = resolveSegmentEnablementMatrix(DEFAULT_INPUT); + expect(allowedLocalActions(m).sort()).toEqual(['edit'].sort()); + expect(allowedRuntimeActions(m).sort()).toEqual(['disable'].sort()); + expect(m.localOverlay.actions.edit.reasonCode).toBeNull(); + expect(m.runtimeOverride.actions.disable.reasonCode).toBeNull(); + expect(runtimeReasonCode(m, 'enable')).toBe('already-enabled'); + expect(runtimeReasonCode(m, 'rollback')).toBe('no-override'); + expect(localReasonCode(m, 'restoreBackup')).toBe('no-backup'); + expect(runtimeReasonCode(m, 'activateVersion')).toBe('no-version-snapshot'); + }); + + it('readonly blocks content mutations but allows disable when disableable', () => { + const m = resolveSegmentEnablementMatrix({ ...DEFAULT_INPUT, safetyTier: 'readonly' }); + expect(allowedLocalActions(m)).toEqual([]); + expect(allowedRuntimeActions(m)).toEqual(['disable']); + expect(localReasonCode(m, 'edit')).toBe('safety-tier-readonly'); + expect(localReasonCode(m, 'restoreBackup')).toBe('no-backup'); + expect(runtimeReasonCode(m, 'activateVersion')).toBe('no-version-snapshot'); + }); + + it('allowLocalOverride=false blocks edit/restore even when editable', () => { + const m = resolveSegmentEnablementMatrix({ ...DEFAULT_INPUT, allowLocalOverride: false }); + expect(allowedLocalActions(m)).toEqual([]); + expect(allowedRuntimeActions(m)).toEqual(['disable']); + expect(localReasonCode(m, 'edit')).toBe('no-local-overlay-path'); + expect(localReasonCode(m, 'restoreBackup')).toBe('no-backup'); + }); + + it('disableable=false blocks disable but leaves edit intact', () => { + const m = resolveSegmentEnablementMatrix({ ...DEFAULT_INPUT, disableable: false }); + expect(allowedLocalActions(m)).toEqual(['edit']); + expect(allowedRuntimeActions(m)).toEqual([]); + expect(runtimeReasonCode(m, 'disable')).toBe('not-disableable'); + }); + + it('disabled override enables enable action and blocks disable', () => { + const m = resolveSegmentEnablementMatrix({ + ...DEFAULT_INPUT, + runtimeOverride: { + enabled: false, + hasOverride: true, + hasContentOverride: false, + hasVersionSnapshot: false, + availableEpochVersions: [], + }, + }); + expect(allowedLocalActions(m).sort()).toEqual(['edit'].sort()); + expect(allowedRuntimeActions(m).sort()).toEqual(['enable', 'rollback'].sort()); + expect(runtimeReasonCode(m, 'disable')).toBe('already-disabled'); + expect(runtimeReasonCode(m, 'enable')).toBeNull(); + }); + + it('content override enables rollback; version snapshot enables activateVersion', () => { + const m = resolveSegmentEnablementMatrix({ + ...DEFAULT_INPUT, + localOverlay: { hasOverlay: true, hasBackup: true }, + runtimeOverride: { + enabled: true, + hasOverride: true, + hasContentOverride: true, + hasVersionSnapshot: true, + availableEpochVersions: [2, 3], + }, + }); + expect(allowedLocalActions(m).sort()).toEqual(['edit', 'reset', 'restoreBackup'].sort()); + expect(allowedRuntimeActions(m).sort()).toEqual(['activateVersion', 'disable', 'rollback'].sort()); + }); + + it('readonly blocks local edit even when allowLocalOverride=true', () => { + const m = resolveSegmentEnablementMatrix({ + ...DEFAULT_INPUT, + safetyTier: 'readonly', + allowLocalOverride: true, + }); + expect(allowedLocalActions(m)).toEqual([]); + expect(localReasonCode(m, 'edit')).toBe('safety-tier-readonly'); + // restoreBackup is blocked by the absence of a backup before safetyTier is reached. + expect(localReasonCode(m, 'restoreBackup')).toBe('no-backup'); + }); + + it('readonly + no overlay path: reason prefers safety-tier over no-overlay when backup exists', () => { + const m = resolveSegmentEnablementMatrix({ + ...DEFAULT_INPUT, + safetyTier: 'readonly', + allowLocalOverride: false, + localOverlay: { hasOverlay: false, hasBackup: true }, + }); + expect(localReasonCode(m, 'edit')).toBe('safety-tier-readonly'); + expect(localReasonCode(m, 'restoreBackup')).toBe('safety-tier-readonly'); + }); + + it('limited-edit does not block matrix edit (source gate enforced server-side)', () => { + const m = resolveSegmentEnablementMatrix({ ...DEFAULT_INPUT, safetyTier: 'limited-edit' }); + expect(m.localOverlay.actions.edit.allowed).toBe(true); + expect(m.runtimeOverride.actions.activateVersion.allowed).toBe(false); + expect(runtimeReasonCode(m, 'activateVersion')).toBe('no-version-snapshot'); + }); + + it('disabled without override cannot be enabled', () => { + const m = resolveSegmentEnablementMatrix({ + ...DEFAULT_INPUT, + runtimeOverride: { + enabled: false, + hasOverride: false, + hasContentOverride: false, + hasVersionSnapshot: false, + availableEpochVersions: [], + }, + }); + expect(runtimeReasonCode(m, 'enable')).toBe('no-disable-override'); + }); + + it('exposes dimension fields on matrix', () => { + const m = resolveSegmentEnablementMatrix({ + ...DEFAULT_INPUT, + safetyTier: 'limited-edit', + disableable: false, + }); + expect(m.segmentId).toBe('S1'); + expect(m.safetyTier).toBe('limited-edit'); + expect(m.allowLocalOverride).toBe(true); + expect(m.disableable).toBe(false); + expect(m.runtimeOverride.enabled).toBe(true); + }); + + it('activateVersion allowed after rollback because snapshots remain', () => { + const m = resolveSegmentEnablementMatrix({ + ...DEFAULT_INPUT, + runtimeOverride: { + enabled: true, + hasOverride: false, + hasContentOverride: false, + hasVersionSnapshot: true, + availableEpochVersions: [2], + }, + }); + expect(allowedRuntimeActions(m)).toContain('activateVersion'); + expect(runtimeReasonCode(m, 'activateVersion')).toBeNull(); + }); +}); diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index 31eeeae205..74f55e8100 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -3,4 +3,5 @@ */ export * from './redis.js'; +export * from './segment-enablement.js'; export * from './workspace-paths.js'; diff --git a/packages/shared/src/utils/segment-enablement.ts b/packages/shared/src/utils/segment-enablement.ts new file mode 100644 index 0000000000..cac9f15e03 --- /dev/null +++ b/packages/shared/src/utils/segment-enablement.ts @@ -0,0 +1,200 @@ +/** + * F257 Console 判据⑥ — Segment enablement matrix. + * + * Centralizes the `safetyTier × allowLocalOverride × disableable × overrideState` + * decision table so API, read-model, and Console UI share one contract. + * + * The matrix is split into two independent storage planes so no action name is + * overloaded: + * - localOverlay → filesystem `.local` overlay files (editor / backup / reset) + * - runtimeOverride → Redis-backed HookOverrideStore (disable/enable/rollback/activateVersion) + */ + +import type { HookManifest, SafetyTier } from '../types/prompt-hook.js'; + +export type SegmentLocalOverlayAction = 'edit' | 'restoreBackup' | 'reset'; +export type SegmentRuntimeOverrideAction = 'disable' | 'enable' | 'rollback' | 'activateVersion'; + +export interface SegmentActionPermission { + allowed: boolean; + /** Human-readable reason when blocked; null when allowed. */ + reason: string | null; + /** Machine-readable reason code when blocked; null when allowed. */ + reasonCode: string | null; +} + +export interface SegmentLocalOverlayState { + /** A `.local` overlay file exists for this segment. */ + hasOverlay: boolean; + /** A `.local.bak` rollback snapshot exists. */ + hasBackup: boolean; + actions: Record; +} + +export interface SegmentRuntimeOverrideState { + /** Effective enabled state (override false → manifest baseline true). */ + enabled: boolean; + /** Any runtime override record exists in the store. */ + hasOverride: boolean; + /** A content override is currently active. */ + hasContentOverride: boolean; + /** At least one historical version snapshot is retained. */ + hasVersionSnapshot: boolean; + /** Epoch versions available for activation via HookOverrideStore snapshots. */ + availableEpochVersions: number[]; + actions: Record; +} + +export interface SegmentEnablementMatrix { + segmentId: string; + safetyTier: SafetyTier; + allowLocalOverride: boolean; + disableable: boolean; + localOverlay: SegmentLocalOverlayState; + runtimeOverride: SegmentRuntimeOverrideState; +} + +export interface SegmentLocalOverlayInput { + hasOverlay: boolean; + hasBackup: boolean; +} + +export interface SegmentRuntimeOverrideInput { + enabled: boolean; + hasOverride: boolean; + hasContentOverride: boolean; + hasVersionSnapshot: boolean; + availableEpochVersions: number[]; +} + +export interface ResolveSegmentEnablementMatrixInput { + segmentId: string; + safetyTier: SafetyTier; + allowLocalOverride: boolean; + disableable: boolean; + localOverlay: SegmentLocalOverlayInput; + runtimeOverride: SegmentRuntimeOverrideInput; +} + +/** Compute the unified enablement matrix for a segment. */ +export function resolveSegmentEnablementMatrix(input: ResolveSegmentEnablementMatrixInput): SegmentEnablementMatrix { + const { segmentId, safetyTier, allowLocalOverride, disableable, localOverlay, runtimeOverride } = input; + + const readonlyContent = safetyTier === 'readonly'; + const noOverlayPath = !allowLocalOverride; + const canEditContent = !readonlyContent && !noOverlayPath; + + const localActions: Record = { + edit: { + allowed: canEditContent, + reason: canEditContent + ? null + : readonlyContent + ? '当前段 safetyTier=readonly,禁止编辑内容' + : '当前段无本地覆盖路径,不可编辑', + reasonCode: canEditContent ? null : readonlyContent ? 'safety-tier-readonly' : 'no-local-overlay-path', + }, + restoreBackup: { + allowed: localOverlay.hasBackup && canEditContent, + reason: localOverlay.hasBackup + ? canEditContent + ? null + : readonlyContent + ? '当前段 safetyTier=readonly,禁止恢复备份' + : '当前段无本地覆盖路径,不可恢复备份' + : '当前段无备份文件', + reasonCode: + localOverlay.hasBackup && canEditContent + ? null + : !localOverlay.hasBackup + ? 'no-backup' + : readonlyContent + ? 'safety-tier-readonly' + : 'no-local-overlay-path', + }, + reset: { + allowed: localOverlay.hasOverlay, + reason: localOverlay.hasOverlay ? null : '当前段无本地覆盖可重置', + reasonCode: localOverlay.hasOverlay ? null : 'no-local-overlay', + }, + }; + + const runtimeActions: Record = { + disable: { + allowed: disableable && runtimeOverride.enabled, + reason: disableable ? (runtimeOverride.enabled ? null : '当前段已禁用') : '当前段 disableable=false,不可禁用', + reasonCode: disableable ? (runtimeOverride.enabled ? null : 'already-disabled') : 'not-disableable', + }, + enable: { + allowed: !runtimeOverride.enabled && runtimeOverride.hasOverride, + reason: + !runtimeOverride.enabled && runtimeOverride.hasOverride + ? null + : runtimeOverride.enabled + ? '当前段已启用' + : '当前段无禁用覆盖可启用', + reasonCode: + !runtimeOverride.enabled && runtimeOverride.hasOverride + ? null + : runtimeOverride.enabled + ? 'already-enabled' + : 'no-disable-override', + }, + rollback: { + allowed: runtimeOverride.hasOverride, + reason: runtimeOverride.hasOverride ? null : '当前段无覆盖可回滚', + reasonCode: runtimeOverride.hasOverride ? null : 'no-override', + }, + activateVersion: { + allowed: runtimeOverride.hasVersionSnapshot && !readonlyContent, + reason: runtimeOverride.hasVersionSnapshot + ? readonlyContent + ? '当前段 safetyTier=readonly,禁止激活版本' + : null + : '当前段无保留版本可激活', + reasonCode: + runtimeOverride.hasVersionSnapshot && !readonlyContent + ? null + : !runtimeOverride.hasVersionSnapshot + ? 'no-version-snapshot' + : 'safety-tier-readonly', + }, + }; + + return { + segmentId, + safetyTier, + allowLocalOverride, + disableable, + localOverlay: { + hasOverlay: localOverlay.hasOverlay, + hasBackup: localOverlay.hasBackup, + actions: localActions, + }, + runtimeOverride: { + enabled: runtimeOverride.enabled, + hasOverride: runtimeOverride.hasOverride, + hasContentOverride: runtimeOverride.hasContentOverride, + hasVersionSnapshot: runtimeOverride.hasVersionSnapshot, + availableEpochVersions: runtimeOverride.availableEpochVersions, + actions: runtimeActions, + }, + }; +} + +/** Convenience: build matrix from a hook manifest + runtime state. */ +export function resolveSegmentEnablementMatrixFromManifest( + manifest: Pick, + allowLocalOverride: boolean, + localOverlay: SegmentLocalOverlayInput, + runtimeOverride: SegmentRuntimeOverrideInput, +): SegmentEnablementMatrix { + return resolveSegmentEnablementMatrix({ + segmentId: manifest.id, + safetyTier: manifest.safetyTier, + allowLocalOverride, + disableable: manifest.disableable, + localOverlay, + runtimeOverride, + }); +} diff --git a/packages/shared/vitest.config.js b/packages/shared/vitest.config.js index a9733c2814..22115b4e32 100644 --- a/packages/shared/vitest.config.js +++ b/packages/shared/vitest.config.js @@ -12,6 +12,7 @@ export default defineConfig({ 'src/__tests__/dispatch-proposal-types.test.ts', 'src/__tests__/load-dossier-profiles.test.ts', 'src/__tests__/parse-dossier-profiles.test.ts', + 'src/utils/__tests__/segment-enablement.test.ts', ], }, }); From 93435ef4a76dab213b994f2ec053419441f6027e Mon Sep 17 00:00:00 2001 From: mindfn Date: Wed, 29 Jul 2026 22:33:12 +0800 Subject: [PATCH 02/15] =?UTF-8?q?feat(f257):=20runtime=20base=20=E2=80=94?= =?UTF-8?q?=20routing,=20prompt-hooks,=20guard,=20harness=20eval=20and=20v?= =?UTF-8?q?erdict=20publisher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R6 split: B — runtime base. Includes internal services, message stores, routing, prompt-hooks, guard rejection event log, harness eval, local artifact publisher, telemetry, ball-custody, and existing route adaptations. Provenance is optional in MessageStore at this layer so upstream callers still compile. Old Git publisher removed in this commit alongside type sunset. --- packages/api/src/config/cat-config-loader.ts | 24 +- packages/api/src/config/cat-uniqueness.ts | 127 ++ .../api/src/config/runtime-cat-catalog.ts | 38 + .../ball-custody/ball-custody-events.ts | 32 + .../ball-custody-state-machine.ts | 4 +- .../agents/invocation/InvocationQueue.ts | 7 + .../agents/invocation/QueueProcessor.ts | 1 + .../agents/invocation/StartupReconciler.ts | 1 + .../providers/BgTranscriptEventConsumer.ts | 12 + .../providers/HookSidechannelConsumer.ts | 54 +- .../agents/providers/claude-ndjson-parser.ts | 78 +- .../services/agents/providers/claude-usage.ts | 48 + .../services/agents/providers/l0-compiler.ts | 87 +- .../agents/providers/pty/hook-setup.ts | 4 + .../services/agents/routing/AgentRouter.ts | 298 ++- .../agents/routing/a2a-ack-liveness.ts | 185 ++ .../services/agents/routing/a2a-mentions.ts | 243 ++- .../agents/routing/cat-signature-lint.ts | 107 ++ .../agents/routing/cat-target-resolver.ts | 76 +- .../agents/routing/format-briefing.ts | 1 + .../routing/guards/routing-guard-remedial.ts | 37 + .../services/agents/routing/route-helpers.ts | 7 + .../services/agents/routing/route-parallel.ts | 165 +- .../services/agents/routing/route-serial.ts | 696 ++++++- .../agents/routing/routing-attempt.ts | 188 ++ .../agents/routing/routing-decision.ts | 6 +- .../agents/routing/speech-mention-map.ts | 90 + .../context/prompt-template-loader.ts | 108 +- .../duty-briefing/briefing-delivery.ts | 1 + .../frustration/FrustrationDetector.ts | 1 + .../cats/services/game/GameOrchestrator.ts | 1 + .../cats/services/game/gameSystemMessage.ts | 1 + .../session/BoundSessionHistoryImporter.ts | 6 + .../stores/factories/MessageStoreFactory.ts | 18 +- .../services/stores/ports/MessageStore.ts | 221 ++- .../stores/redis-keys/routing-fact-keys.ts | 22 + .../stores/redis/RedisMessageStore.ts | 461 ++++- .../redis/RedisRoutingFactProjection.ts | 600 ++++++ .../redis-message-delivery-lua-scripts.ts | 106 +- .../stores/redis/redis-message-parsers.ts | 313 +++- .../api/src/domains/cats/services/types.ts | 2 + .../src/domains/memory/EventMemoryStore.ts | 213 ++- packages/api/src/domains/memory/schema.ts | 11 +- .../domains/prompt-hooks/HookOverrideStore.ts | 319 ++++ .../src/domains/prompt-hooks/HookPipeline.ts | 62 +- .../src/domains/prompt-hooks/HookRegistry.ts | 99 +- .../prompt-hooks/InjectionTraceStore.ts | 213 ++- .../prompt-hooks/PipelinePromptBuilder.ts | 39 +- .../prompt-hooks/SegmentJudgmentCache.ts | 207 +++ .../prompt-hooks/hook-manifest-parser.ts | 61 +- .../hook-override-event-recorder.ts | 114 ++ .../domains/prompt-hooks/l0-manifest-trace.ts | 89 + .../domains/prompt-hooks/native-l0-trace.ts | 89 + .../src/domains/prompt-hooks/trace-bridge.ts | 374 ++++ .../domains/prompt-hooks/trace-collector.ts | 17 + .../signals/services/podcast-generator.ts | 1 + packages/api/src/index.ts | 182 +- .../connectors/ConnectorRouter.ts | 6 + .../connectors/connector-gateway-bootstrap.ts | 1 + .../email/ConnectorInvokeTrigger.ts | 14 +- .../email/deliver-connector-message.ts | 1 + .../harness-eval/GuardRejectionEventLog.ts | 341 ++++ .../eval-capability-wakeup-live-verdict.ts | 6 +- .../deviation/DeviationEventLog.ts | 192 ++ .../harness-eval/deviation/deviation-event.ts | 168 ++ .../deviation/report-harness-signal.ts | 198 ++ .../harness-eval/domain/eval-domain-daily.ts | 180 +- .../domain/eval-domain-evidence-gate.ts | 104 ++ .../domain/eval-domain-messages.ts | 116 ++ .../harness-eval/domain/eval-domain-nday.ts | 15 + .../harness-eval/eval-cat-invocation.ts | 89 +- .../friction/eval-friction-live-verdict.ts | 4 +- .../friction-metrics-provider-impl.ts | 18 + .../friction/guard-anomaly-adapter.ts | 80 + .../harness-eval/guard-episode-coalescing.ts | 322 ++++ .../harness-eval/guard-ledger-registry.ts | 127 ++ .../harness-eval/guard-rejection-constants.ts | 16 + .../guard-threshold-escalation.ts | 402 ++++ .../harness-ledger-snapshot-provider.ts | 298 +++ .../harness-eval/hub/eval-hub-read-model.ts | 104 +- .../manual-trigger/generate-now.ts | 194 +- .../manual-trigger/trigger-now-judgments.ts | 87 + .../manual-trigger/trigger-now.ts | 104 +- .../harness-eval/manual-trigger/types.ts | 20 + .../memory/eval-memory-live-verdict.ts | 8 +- .../harness-eval/objective-registry.ts | 130 ++ .../publish-verdict/a2a-generator-adapter.ts | 2 +- .../capability-wakeup-generator-adapter.ts | 13 +- .../publish-verdict/error-mapping.ts | 3 + .../publish-verdict/git-worktree-publisher.ts | 257 --- .../harness-ledger-generator-adapter.ts | 221 +++ .../harness-ledger-verdict-builders.ts | 183 ++ .../local-artifact-publisher.ts | 124 ++ .../memory-generator-adapter.ts | 7 +- .../publish-verdict/publish-verdict.ts | 144 +- .../harness-eval/publish-verdict/types.ts | 117 +- .../publish-verdict/validation.ts | 41 + .../harness-eval/segment-judgment-engine.ts | 339 ++++ .../harness-eval/skip-reason-eligibility.ts | 142 ++ .../task-outcome/magic-word-metric.ts | 306 ++++ .../task-outcome-signal-wiring.ts | 7 +- .../task-outcome/task-outcome-store.ts | 110 ++ .../scheduler/DynamicTaskStore.ts | 21 +- .../infrastructure/scheduler/TaskRunnerV2.ts | 70 +- .../src/infrastructure/scheduler/delivery.ts | 21 +- .../scheduler/templates/reminder.ts | 13 +- .../api/src/infrastructure/scheduler/types.ts | 16 + .../infrastructure/telemetry/instruments.ts | 16 + packages/api/src/routes/backlog.ts | 1 + .../routes/callback-auth-system-message.ts | 1 + .../api/src/routes/callback-docs-routes.ts | 33 +- .../routes/callback-guard-rejection-routes.ts | 241 +++ .../callback-hold-ball-cancel-routes.ts | 1 + .../src/routes/callback-hold-ball-routes.ts | 90 +- .../routes/callback-multi-mention-routes.ts | 1 + .../callback-propose-profile-update-routes.ts | 1 + ...callback-propose-session-handoff-routes.ts | 1 + .../routes/callback-propose-thread-routes.ts | 1 + packages/api/src/routes/callbacks.ts | 280 ++- packages/api/src/routes/eval-hub.ts | 103 +- packages/api/src/routes/messages.ts | 37 +- .../api/src/routes/prompt-injection-hooks.ts | 29 +- .../src/routes/prompt-injection-manifest.ts | 153 +- .../src/routes/prompt-injection-preview.ts | 3 + packages/api/src/routes/prompt-injection.ts | 344 +++- .../src/routes/proposal-approve-dispatch.ts | 12 + packages/api/src/routes/schedule.ts | 1 + packages/api/src/routes/thread-branch.ts | 35 +- packages/api/src/routes/votes.ts | 3 + packages/api/test/a2a-ack-liveness.test.js | 244 +++ packages/api/test/a2a-routing-persist.test.js | 9 + .../test/agent-router-speech-mentions.test.js | 12 +- packages/api/test/agent-router.test.js | 26 + .../api/test/auto-reply-to-worklist.test.js | 4 + packages/api/test/auto-reply-to.test.js | 5 + .../test/ball-custody-state-machine.test.js | 11 +- .../api/test/bg-transcript-parity.test.js | 53 + .../api/test/callback-a2a-postmsg.test.js | 41 + packages/api/test/callback-docs-route.test.js | 44 +- .../callback-guard-rejection-route.test.js | 403 ++++ .../test/callback-hold-ball-wakewhen.test.js | 34 + ...back-propose-profile-update-routes.test.js | 2 + .../test/callback-routes-agent-key.test.js | 1 + packages/api/test/callback-routes.test.js | 172 +- packages/api/test/cat-catalog-store.test.js | 12 +- .../api/test/claude-ndjson-parser.test.js | 137 ++ packages/api/test/commands-route.test.js | 3 + packages/api/test/concierge-a3b-route.test.js | 1 + .../api/test/concurrent-fault-drill.test.js | 4 + .../api/test/connector-invoke-trigger.test.js | 29 + packages/api/test/connector-router.test.js | 5 + packages/api/test/cursor-deferred-ack.test.js | 15 + packages/api/test/delivery-status.test.js | 36 +- packages/api/test/deviation-event-log.test.js | 303 +++ .../api/test/draft-messages-merge.test.js | 18 + .../api/test/duty-briefing-e2e-redis.test.js | 1 + .../test/f148-assemble-incremental.test.js | 5 + .../f194-canonical-liveness-routes.test.js | 7 + .../f194-phase-z-routes-integration.test.js | 7 + packages/api/test/f230-hook-setup.test.js | 9 +- .../f230-hook-sidechannel-consumer.test.js | 136 +- .../f232-thread-artifacts-aggregator.test.js | 21 +- .../f232-thread-artifacts-endpoint.test.js | 2 + .../test/f232-thread-artifacts-redis.test.js | 3 + .../test/f257-active-actionable-stage.test.js | 355 ++++ packages/api/test/f257-eval-window.test.js | 587 ++++++ .../f257-fix1-4path-mismatch-matrix.test.js | 345 ++++ .../test/f257-fix1-ambiguous-mention.test.js | 173 ++ .../test/f257-fix1-callback-ambiguity.test.js | 371 ++++ .../test/f257-fix1-config-uniqueness.test.js | 312 ++++ .../test/f257-fix1-nickname-ambiguity.test.js | 121 ++ .../api/test/f257-l0-manifest-cli.test.js | 102 ++ packages/api/test/f257-l0-manifest.test.js | 120 ++ packages/api/test/f257-lseries-trace.test.js | 342 ++++ .../api/test/f257-objective-registry.test.js | 122 ++ .../f257-replay-snapshot-concurrent.test.js | 197 ++ packages/api/test/f257-route-seam.test.js | 263 +++ .../api/test/f257-routing-attempts.test.js | 788 ++++++++ ...257-signature-lint-redis-roundtrip.test.js | 54 + .../f257-signature-lint-stream-final.test.js | 139 ++ packages/api/test/f257-signature-lint.test.js | 145 ++ packages/api/test/game-command-bridge.test.js | 12 +- packages/api/test/game-phase-h-fixes.test.js | 12 +- .../api/test/get-message-visibility.test.js | 19 + .../test/guard-rejection-event-log.test.js | 308 ++++ .../test/harness-eval/_guard-test-helpers.js | 116 ++ ...val-cat-invocation-publish-verdict.test.js | 9 +- .../harness-eval/eval-domain-daily.test.js | 309 +++- .../eval-domain-evidence-gate.test.js | 234 +++ .../harness-eval/eval-hub-read-model.test.js | 167 +- .../eval-hub-route-newline.test.js | 14 +- .../test/harness-eval/eval-hub-route.test.js | 106 +- .../eval-manual-trigger-fixtures.js | 15 + .../eval-manual-trigger-handlers.test.js | 478 +++-- .../git-worktree-publisher.test.js | 117 -- .../guard-anomaly-adapter.test.js | 212 +++ .../harness-eval/guard-drift-guard.test.js | 154 ++ .../harness-eval/guard-emit-points.test.js | 211 +++ .../guard-episode-coalescing.test.js | 667 +++++++ .../guard-rejection-r3-regression.test.js | 225 +++ .../guard-rejection-r3-routes.test.js | 514 ++++++ .../guard-rejection-r5-route-skip.test.js | 231 +++ .../guard-threshold-escalation.test.js | 654 +++++++ .../harness-ledger-attribution-refs.test.js | 173 ++ .../local-artifact-publisher.test.js | 431 +++++ .../harness-eval/paw-feel-adapter.test.js | 15 +- ...dict-capability-wakeup-owner-scope.test.js | 51 +- ...apability-wakeup-strict-validation.test.js | 2 +- .../publish-verdict-capability-wakeup.test.js | 112 +- .../harness-eval/publish-verdict-fixtures.js | 86 +- .../publish-verdict-friction.test.js | 53 +- .../publish-verdict-memory.test.js | 165 +- .../publish-verdict-pipeline.test.js | 244 --- ...rdict-task-outcome-writeback-guard.test.js | 46 +- .../publish-verdict-task-outcome.test.js | 90 +- .../test/harness-eval/publish-verdict.test.js | 104 +- .../segment-judgment-engine.test.js | 700 +++++++ .../skip-reason-eligibility.test.js | 1171 ++++++++++++ .../task-outcome-signal-chain-e2e.test.js | 56 + .../harness-ledger-generator-adapter.test.js | 489 +++++ .../helpers/incremental-context-helpers.js | 5 + packages/api/test/hook-override-store.test.js | 1621 +++++++++++++++++ packages/api/test/hook-pipeline.test.js | 34 +- packages/api/test/image-upload.test.js | 9 + .../api/test/injection-trace-store.test.js | 398 +++- .../integration/cross-cat-context.test.js | 1 + packages/api/test/integration/history.test.js | 4 + .../test/integration/mcp-prompt-e2e.test.js | 2 + .../test/integration/thread-wiring.test.js | 5 + packages/api/test/invocation-queue.test.js | 22 + packages/api/test/invocations-retry.test.js | 25 + packages/api/test/l0-compiler.test.js | 13 +- .../test/li005-ack-liveness-behavior.test.js | 606 ++++++ packages/api/test/magic-word-metric.test.js | 634 +++++++ packages/api/test/mark-all-read.test.js | 3 + .../test/memory/event-memory-store.test.js | 68 + .../memory/f200-trajectory-schema.test.js | 4 +- packages/api/test/memory/schema-v17.test.js | 4 +- .../api/test/memory/schema-v19-f200.test.js | 4 +- packages/api/test/memory/schema-v2.test.js | 2 +- .../schema-v26-recall-result-count.test.js | 2 +- packages/api/test/memory/schema-v27.test.js | 46 + .../test/memory/world-scope-filter.test.js | 4 +- packages/api/test/mention-ack.test.js | 2 + packages/api/test/mention-parser.test.js | 12 +- .../api/test/message-delivered-at.test.js | 6 + packages/api/test/message-store.test.js | 498 ++++- ...ssages-decision-notification-route.test.js | 7 + .../api/test/messages-delivery-mode.test.js | 35 + packages/api/test/messages-endpoint.test.js | 60 + .../messages-f108b-whisper-dispatch.test.js | 70 + .../api/test/messages-intent-mode.test.js | 7 + .../messages-parallel-slot-release.test.js | 14 + .../test/messages-sender-in-response.test.js | 7 + .../api/test/opencode-mention-routing.test.js | 5 +- .../api/test/pack-knowledge-scope.test.js | 4 +- .../api/test/persistence-fault-drill.test.js | 7 + packages/api/test/pingpong-reset.test.js | 12 +- .../api/test/pipeline-prompt-builder.test.js | 54 + ...prompt-injection-enablement-matrix.test.js | 245 +++ ...prompt-injection-variable-metadata.test.js | 525 ++++++ .../test/prompt-segments-eval-domain.test.js | 187 ++ .../test/proposal-approve-dispatch.test.js | 65 +- .../api/test/proposal-chain-protocol.test.js | 47 +- .../api/test/proposal-explicit-intent.test.js | 26 +- packages/api/test/proposal-phase-aa.test.js | 26 +- .../api/test/proposal-reporter-handle.test.js | 39 +- packages/api/test/proposal-resilience.test.js | 1 + .../api/test/queue-gate-thread-level.test.js | 7 + packages/api/test/queue-processor.test.js | 20 + .../api/test/read-latest-endpoint.test.js | 3 + .../redis-message-delivery-atomicity.test.js | 7 + packages/api/test/redis-message-store.test.js | 745 ++++++-- .../api/test/redis-read-state-store.test.js | 17 + .../redis-routing-fact-projection.test.js | 868 +++++++++ packages/api/test/reminder-template.test.js | 85 + packages/api/test/reply-to-threading.test.js | 8 + packages/api/test/reply-to-validation.test.js | 17 + .../api/test/report-harness-signal.test.js | 287 +++ .../api/test/rich-block-interactive.test.js | 5 + .../test/route-serial-replyto-stream.test.js | 65 +- ...oute-serial-routing-guard-remedial.test.js | 207 ++- packages/api/test/routing-decision.test.js | 2 +- .../api/test/routing-guard-remedial.test.js | 44 + packages/api/test/s1-review-fixes.test.js | 7 + packages/api/test/scheduler-delivery.test.js | 81 +- .../scheduler-reply-userid-backfill.test.js | 6 + .../test/scheduler/dynamic-task-store.test.js | 18 + .../api/test/scheduler/phase4-e2e.test.js | 11 +- .../api/test/scheduler/task-runner-v2.test.js | 298 ++- .../api/test/segment-judgment-cache.test.js | 499 +++++ .../api/test/segment-judgment-engine.test.js | 190 ++ .../test/session-bind-history-import.test.js | 8 +- packages/api/test/soft-delete.test.js | 2 + .../api/test/system-prompt-builder.test.js | 3 +- .../api/test/thread-branch-permission.test.js | 2 + packages/api/test/thread-branch.test.js | 78 + .../test/thread-context-workflow-sop.test.js | 5 + packages/api/test/threads-endpoint.test.js | 7 + packages/api/test/trace-bridge.test.js | 267 +++ packages/api/test/whisper-visibility.test.js | 6 + 301 files changed, 35225 insertions(+), 2706 deletions(-) create mode 100644 packages/api/src/config/cat-uniqueness.ts create mode 100644 packages/api/src/domains/cats/services/agents/providers/claude-usage.ts create mode 100644 packages/api/src/domains/cats/services/agents/routing/a2a-ack-liveness.ts create mode 100644 packages/api/src/domains/cats/services/agents/routing/cat-signature-lint.ts create mode 100644 packages/api/src/domains/cats/services/agents/routing/routing-attempt.ts create mode 100644 packages/api/src/domains/cats/services/agents/routing/speech-mention-map.ts create mode 100644 packages/api/src/domains/cats/services/stores/redis-keys/routing-fact-keys.ts create mode 100644 packages/api/src/domains/cats/services/stores/redis/RedisRoutingFactProjection.ts create mode 100644 packages/api/src/domains/prompt-hooks/HookOverrideStore.ts create mode 100644 packages/api/src/domains/prompt-hooks/SegmentJudgmentCache.ts create mode 100644 packages/api/src/domains/prompt-hooks/hook-override-event-recorder.ts create mode 100644 packages/api/src/domains/prompt-hooks/l0-manifest-trace.ts create mode 100644 packages/api/src/domains/prompt-hooks/native-l0-trace.ts create mode 100644 packages/api/src/domains/prompt-hooks/trace-bridge.ts create mode 100644 packages/api/src/infrastructure/harness-eval/GuardRejectionEventLog.ts create mode 100644 packages/api/src/infrastructure/harness-eval/deviation/DeviationEventLog.ts create mode 100644 packages/api/src/infrastructure/harness-eval/deviation/deviation-event.ts create mode 100644 packages/api/src/infrastructure/harness-eval/deviation/report-harness-signal.ts create mode 100644 packages/api/src/infrastructure/harness-eval/domain/eval-domain-evidence-gate.ts create mode 100644 packages/api/src/infrastructure/harness-eval/domain/eval-domain-messages.ts create mode 100644 packages/api/src/infrastructure/harness-eval/friction/guard-anomaly-adapter.ts create mode 100644 packages/api/src/infrastructure/harness-eval/guard-episode-coalescing.ts create mode 100644 packages/api/src/infrastructure/harness-eval/guard-ledger-registry.ts create mode 100644 packages/api/src/infrastructure/harness-eval/guard-rejection-constants.ts create mode 100644 packages/api/src/infrastructure/harness-eval/guard-threshold-escalation.ts create mode 100644 packages/api/src/infrastructure/harness-eval/harness-ledger-snapshot-provider.ts create mode 100644 packages/api/src/infrastructure/harness-eval/manual-trigger/trigger-now-judgments.ts create mode 100644 packages/api/src/infrastructure/harness-eval/objective-registry.ts delete mode 100644 packages/api/src/infrastructure/harness-eval/publish-verdict/git-worktree-publisher.ts create mode 100644 packages/api/src/infrastructure/harness-eval/publish-verdict/harness-ledger-generator-adapter.ts create mode 100644 packages/api/src/infrastructure/harness-eval/publish-verdict/harness-ledger-verdict-builders.ts create mode 100644 packages/api/src/infrastructure/harness-eval/publish-verdict/local-artifact-publisher.ts create mode 100644 packages/api/src/infrastructure/harness-eval/segment-judgment-engine.ts create mode 100644 packages/api/src/infrastructure/harness-eval/skip-reason-eligibility.ts create mode 100644 packages/api/src/infrastructure/harness-eval/task-outcome/magic-word-metric.ts create mode 100644 packages/api/src/routes/callback-guard-rejection-routes.ts create mode 100644 packages/api/test/a2a-ack-liveness.test.js create mode 100644 packages/api/test/callback-guard-rejection-route.test.js create mode 100644 packages/api/test/deviation-event-log.test.js create mode 100644 packages/api/test/f257-active-actionable-stage.test.js create mode 100644 packages/api/test/f257-eval-window.test.js create mode 100644 packages/api/test/f257-fix1-4path-mismatch-matrix.test.js create mode 100644 packages/api/test/f257-fix1-ambiguous-mention.test.js create mode 100644 packages/api/test/f257-fix1-callback-ambiguity.test.js create mode 100644 packages/api/test/f257-fix1-config-uniqueness.test.js create mode 100644 packages/api/test/f257-fix1-nickname-ambiguity.test.js create mode 100644 packages/api/test/f257-l0-manifest-cli.test.js create mode 100644 packages/api/test/f257-l0-manifest.test.js create mode 100644 packages/api/test/f257-lseries-trace.test.js create mode 100644 packages/api/test/f257-objective-registry.test.js create mode 100644 packages/api/test/f257-replay-snapshot-concurrent.test.js create mode 100644 packages/api/test/f257-route-seam.test.js create mode 100644 packages/api/test/f257-routing-attempts.test.js create mode 100644 packages/api/test/f257-signature-lint-redis-roundtrip.test.js create mode 100644 packages/api/test/f257-signature-lint-stream-final.test.js create mode 100644 packages/api/test/f257-signature-lint.test.js create mode 100644 packages/api/test/guard-rejection-event-log.test.js create mode 100644 packages/api/test/harness-eval/_guard-test-helpers.js create mode 100644 packages/api/test/harness-eval/eval-domain-evidence-gate.test.js delete mode 100644 packages/api/test/harness-eval/git-worktree-publisher.test.js create mode 100644 packages/api/test/harness-eval/guard-anomaly-adapter.test.js create mode 100644 packages/api/test/harness-eval/guard-drift-guard.test.js create mode 100644 packages/api/test/harness-eval/guard-emit-points.test.js create mode 100644 packages/api/test/harness-eval/guard-episode-coalescing.test.js create mode 100644 packages/api/test/harness-eval/guard-rejection-r3-regression.test.js create mode 100644 packages/api/test/harness-eval/guard-rejection-r3-routes.test.js create mode 100644 packages/api/test/harness-eval/guard-rejection-r5-route-skip.test.js create mode 100644 packages/api/test/harness-eval/guard-threshold-escalation.test.js create mode 100644 packages/api/test/harness-eval/harness-ledger-attribution-refs.test.js create mode 100644 packages/api/test/harness-eval/local-artifact-publisher.test.js delete mode 100644 packages/api/test/harness-eval/publish-verdict-pipeline.test.js create mode 100644 packages/api/test/harness-eval/segment-judgment-engine.test.js create mode 100644 packages/api/test/harness-eval/skip-reason-eligibility.test.js create mode 100644 packages/api/test/harness-ledger-generator-adapter.test.js create mode 100644 packages/api/test/hook-override-store.test.js create mode 100644 packages/api/test/li005-ack-liveness-behavior.test.js create mode 100644 packages/api/test/magic-word-metric.test.js create mode 100644 packages/api/test/memory/schema-v27.test.js create mode 100644 packages/api/test/prompt-injection-enablement-matrix.test.js create mode 100644 packages/api/test/prompt-injection-variable-metadata.test.js create mode 100644 packages/api/test/prompt-segments-eval-domain.test.js create mode 100644 packages/api/test/redis-routing-fact-projection.test.js create mode 100644 packages/api/test/report-harness-signal.test.js create mode 100644 packages/api/test/segment-judgment-cache.test.js create mode 100644 packages/api/test/segment-judgment-engine.test.js create mode 100644 packages/api/test/trace-bridge.test.js diff --git a/packages/api/src/config/cat-config-loader.ts b/packages/api/src/config/cat-config-loader.ts index 8aa5a72370..9346bdc979 100644 --- a/packages/api/src/config/cat-config-loader.ts +++ b/packages/api/src/config/cat-config-loader.ts @@ -24,6 +24,7 @@ import { type ClientId, catRegistry, createCatId, normalizeCliEffortForProvider import { z } from 'zod'; import { createModuleLogger } from '../infrastructure/logger.js'; import { bootstrapCatCatalog, readCatCatalogRaw } from './cat-catalog-store.js'; +import { assertNoCrossCatPatternConflicts, warnOnNicknameConflicts } from './cat-uniqueness.js'; import { resolveProjectTemplatePath } from './project-template-path.js'; import { hasOccupiedMentionAlias, @@ -505,7 +506,14 @@ function parseCatConfig(raw: string): CatCafeConfig { // Zod output has mutable arrays + plain string catId; // CatCafeConfig has readonly arrays + branded CatId. // The shapes match at runtime after validation. - return result.data as unknown as CatCafeConfig; + const parsed = result.data as unknown as CatCafeConfig; + + // F257 #1: expand once at parse time so EVERY load path is covered by the + // cross-cat checks — toAllCatConfigs throws on pattern conflicts (fail-closed) + // and duplicate catIds; nickname conflicts are warn-only (legacy data must + // still boot — 宪宪×3/砚砚×5 existed in production catalogs when this landed). + warnOnNicknameConflicts(toAllCatConfigs(parsed)); + return parsed; } export function loadResolvedCatConfig(templatePath?: string): CatCafeConfig { @@ -587,7 +595,12 @@ export function toAllCatConfigs(config: CatCafeConfig): Record, key: string, display: string, catId: string): void { + const entry = map.get(key); + if (entry) { + entry.holders.push(catId); + return; + } + map.set(key, { display, holders: [catId] }); +} + +/** 展开后的 per-cat 配置 → 跨猫 pattern / nickname 冲突清单(纯函数) */ +export function collectCrossCatConflicts(configs: Record): CrossCatConflicts { + const patternHolders = new Map(); + const nicknameHolders = new Map(); + + for (const [catId, config] of Object.entries(configs)) { + const ownPatterns = new Set(); + for (const pattern of config.mentionPatterns) { + const key = normalizeMentionAlias(pattern); + // 同猫内部重复(breed/variant 同值、大小写变体)不是跨猫冲突 + if (!key || ownPatterns.has(key)) continue; + ownPatterns.add(key); + addHolder(patternHolders, key, pattern.trim(), catId); + } + + if (config.nickname) { + const key = normalizeNickname(config.nickname); + if (key) addHolder(nicknameHolders, key, config.nickname.trim(), catId); + } + } + + const toConflicts = (map: Map, build: (entry: HolderEntry) => T): T[] => + [...map.values()].filter((entry) => entry.holders.length > 1).map(build); + + return { + patternConflicts: toConflicts(patternHolders, (e) => ({ pattern: e.display, holders: e.holders })), + nicknameConflicts: toConflicts(nicknameHolders, (e) => ({ nickname: e.display, holders: e.holders })), + }; +} + +/** + * fail-closed 出口:mentionPatterns 跨猫冲突 → 抛错。 + * 调用点 = toAllCatConfigs(加载 / 写入冒烟 / registry 构建的共同必经点)。 + */ +export function assertNoCrossCatPatternConflicts(configs: Record): void { + const { patternConflicts } = collectCrossCatConflicts(configs); + if (patternConflicts.length === 0) return; + const detail = patternConflicts + .map((c) => `mention pattern "${c.pattern}" is shared by cats [${c.holders.join(', ')}]`) + .join('; '); + throw new Error( + `Cross-cat mention pattern conflict (fail-closed, F257 #1 / dev-628ea4d1): ${detail}. ` + + 'Each mention pattern must resolve to exactly one cat — fix cat-template.json / .cat-cafe/cat-catalog.json ' + + 'so every pattern has a single holder.', + ); +} + +/** 进程内同一冲突集合只告警一次(loadCatConfig 为中频调用,避免日志刷屏) */ +const warnedSignatures = new Set(); + +/** + * nickname 跨猫冲突结构化告警(不阻断)。返回冲突清单便于调用方/测试消费。 + * fail-closed 不适用的原因见文件头「分级契约」。 + */ +export function warnOnNicknameConflicts(configs: Record): readonly NicknameConflict[] { + const { nicknameConflicts } = collectCrossCatConflicts(configs); + if (nicknameConflicts.length === 0) return nicknameConflicts; + const signature = JSON.stringify( + nicknameConflicts + .map((c) => [c.nickname, [...c.holders].sort()]) + .sort((a, b) => String(a[0]).localeCompare(String(b[0]))), + ); + if (!warnedSignatures.has(signature)) { + warnedSignatures.add(signature); + log.warn( + { conflicts: nicknameConflicts.map((c) => ({ nickname: c.nickname, holders: c.holders })) }, + 'Cross-cat nickname conflict (F257 #1, warn-only for legacy data): nicknames must be per-cat unique — ' + + 'release or rename via the cat editor. New conflicts are rejected at write time.', + ); + } + return nicknameConflicts; +} diff --git a/packages/api/src/config/runtime-cat-catalog.ts b/packages/api/src/config/runtime-cat-catalog.ts index f815b460c5..3ba97c1da7 100644 --- a/packages/api/src/config/runtime-cat-catalog.ts +++ b/packages/api/src/config/runtime-cat-catalog.ts @@ -16,6 +16,7 @@ import { clearBudgetCache } from './cat-budgets.js'; import { bootstrapCatCatalog, readCatCatalog, resolveCatCatalogPath } from './cat-catalog-store.js'; import type { AcpVariantConfig } from './cat-config-loader.js'; import { _resetCachedConfig, loadCatConfig, toAllCatConfigs } from './cat-config-loader.js'; +import { normalizeNickname } from './cat-uniqueness.js'; import { clearVoiceCache } from './cat-voices.js'; import { resolveProjectTemplatePath } from './project-template-path.js'; import { addTemplateVariantTombstone, type TemplateVariantTombstoneInput } from './template-variant-tombstones.js'; @@ -179,6 +180,29 @@ function validatePersistedCatalog(projectRoot: string): CatCafeConfig { return loadCatConfig(join(projectRoot, '.cat-cafe', 'cat-catalog.json')); } +/** + * F257 #1: incremental nickname uniqueness at write time (dev-628ea4d1). + * Scope is deliberately per-write (only the cat being written), NOT whole-catalog: + * legacy catalogs carry pre-existing conflicts (宪宪×3 / 砚砚×5) and a whole-catalog + * assert would deadlock convergence — no single-step edit could ever pass while any + * other pair still conflicts. Clearing / renaming one cat at a time must succeed; + * load-time warnOnNicknameConflicts keeps the remaining legacy conflicts visible. + */ +function assertNicknameAvailable(catalog: CatCafeConfig, nickname: string | undefined, selfCatId: string): void { + if (!nickname) return; + const key = normalizeNickname(nickname); + if (!key) return; + for (const [catId, config] of Object.entries(toAllCatConfigs(catalog))) { + if (catId === selfCatId) continue; + if (config.nickname && normalizeNickname(config.nickname) === key) { + throw new Error( + `nickname "${nickname.trim()}" is already used by cat "${catId}" — nicknames are per-cat unique (F257 #1). ` + + `Release it from "${catId}" first or choose another nickname.`, + ); + } + } +} + function assertUniqueMentionAliases(catalog: CatCafeConfig): void { const aliasHolders = new Map(); for (const [catId, config] of Object.entries(toAllCatConfigs(catalog))) { @@ -319,6 +343,8 @@ export function createRuntimeCat(projectRoot: string, input: RuntimeCatInput): C if (findBreedVariant(catalog as unknown as CatCafeConfig, input.catId)) { throw new Error(`Cat "${input.catId}" already exists in runtime catalog`); } + // F257 #1: fail-closed on introducing a nickname another cat already holds + assertNicknameAvailable(catalog as unknown as CatCafeConfig, input.nickname, input.catId); const nextBreed = createBreedFromInput(input) as unknown as Record; catalog.breeds = [...catalog.breeds, nextBreed]; if (catalog.version === 2) { @@ -356,6 +382,18 @@ export function updateRuntimeCat(projectRoot: string, catId: string, patch: Runt } if (patch.nickname !== undefined) { const nickname = patch.nickname.trim(); + // F257 #1: reject taking a nickname held by ANOTHER cat. Clearing ('') and + // no-change writes always pass — a legacy catalog may hold pre-existing + // conflicts (砚砚×5), and an unchanged value introduces no NEW conflict, so + // blocking it would break unrelated edits on already-conflicted cats. + const currentNickname = toAllCatConfigs(catalog as unknown as CatCafeConfig)[catId]?.nickname; + const isUnchanged = + nickname.length > 0 && + currentNickname != null && + normalizeNickname(currentNickname) === normalizeNickname(nickname); + if (!isUnchanged) { + assertNicknameAvailable(catalog as unknown as CatCafeConfig, nickname, catId); + } if (shouldWriteBreedIdentity) { if (nickname.length > 0) { breed.nickname = nickname; diff --git a/packages/api/src/domains/ball-custody/ball-custody-events.ts b/packages/api/src/domains/ball-custody/ball-custody-events.ts index 5a3012ad56..7474e8dd23 100644 --- a/packages/api/src/domains/ball-custody/ball-custody-events.ts +++ b/packages/api/src/domains/ball-custody/ball-custody-events.ts @@ -67,6 +67,38 @@ export function buildVoidPassEvent(input: VoidPassEventInput): BallCustodyEvent }; } +export interface VoidAckEventInput { + threadId: string; + /** 触发虚空接球检测的消息 id(A2A 接球但无持久触发器绑定) */ + messageId: string; + /** + * A2A trigger message ID — the message that dispatched this invocation. + * Covers both inline serial (worklist a2aTriggerMessageId) and queue-dispatched + * (options.a2aTriggerMessageId → queueTriggerReplyTo) paths. + * Provides provenance for O2 sender-side discipline analysis. + */ + a2aTriggerMessageId?: string; + /** Unix ms */ + at: number; +} + +/** + * LI-005 虚空接球守卫(A2A 接球但无 hold_ball / create_task / 无行首 @ / 无 structured 路由) + * → ball.void_ack。与 void_pass 互补——void_pass 查"说持球没做",void_ack 查"接了球没绑触发器"。 + */ +export function buildVoidAckEvent(input: VoidAckEventInput): BallCustodyEvent { + return { + sourceEventId: `route:${input.messageId}:void_ack`, + subjectKey: `ball:thread:${input.threadId}`, + kind: 'ball.void_ack', + classification: 'state-changing', + payload: { + ...(input.a2aTriggerMessageId ? { a2aTriggerMessageId: input.a2aTriggerMessageId } : {}), + }, + at: input.at, + }; +} + export interface HandedCvoEventInput { fromCatId?: string; threadId: string; diff --git a/packages/api/src/domains/ball-custody/ball-custody-state-machine.ts b/packages/api/src/domains/ball-custody/ball-custody-state-machine.ts index 664eedb7e2..89b821a68e 100644 --- a/packages/api/src/domains/ball-custody/ball-custody-state-machine.ts +++ b/packages/api/src/domains/ball-custody/ball-custody-state-machine.ts @@ -5,7 +5,7 @@ * (TRANSITION_TABLE 而非 if-chain,降单函数 cognitive complexity)。 * 调用方(projector)负责持久化 + 字段 effect(heldUntil/blockedSinceAt/lastWakeAt)。 * - * INV-10(完整性):全 8 state × 17 event 的每格行为确定(转移 or 显式 reject),穷举测试钉死。 + * INV-10(完整性):全 8 state × 18 event 的每格行为确定(转移 or 显式 reject),穷举测试钉死。 * 复杂守卫拆成独立 resolver: * - ball.handed_cvo:payload.intent 三态(handoff→parked / done_notify→resolved / fyi→不变) * - ball.hold_expired:需 payload.fireAt 匹配 snapshot.heldUntil,防旧 reminder 误杀新 hold @@ -32,6 +32,7 @@ export const ALL_BALL_EVENT_KINDS: BallCustodyEvent['kind'][] = [ 'ball.handed', 'ball.handed_cvo', 'ball.void_pass', + 'ball.void_ack', 'ball.held', 'ball.hold_expired', 'invocation.started', @@ -109,6 +110,7 @@ type DynamicRule = { const STATIC_TABLE: Partial> = { 'ball.handed': { from: '*', to: 'active' }, // 任意(含 resolved=reopen)→ active 'ball.void_pass': { from: set('new', 'active', 'blocked', 'parked'), to: 'void' }, + 'ball.void_ack': { from: set('new', 'active', 'blocked', 'parked'), to: 'void' }, 'ball.held': { from: set('new', 'active'), to: 'active' }, // heldUntil 由 projector 设 'invocation.started': { from: set('active', 'blocked'), to: 'active' }, 'invocation.died': { from: set('active', 'blocked'), to: 'dead' }, // lastScanAt 由 projector 设 diff --git a/packages/api/src/domains/cats/services/agents/invocation/InvocationQueue.ts b/packages/api/src/domains/cats/services/agents/invocation/InvocationQueue.ts index 40dc0098e9..f61e6eeacb 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/InvocationQueue.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/InvocationQueue.ts @@ -13,6 +13,7 @@ import { randomUUID } from 'node:crypto'; import { createModuleLogger } from '../../../../../infrastructure/logger.js'; import type { CallerTraceContext } from '../../../../../infrastructure/telemetry/genai-semconv.js'; +import type { CompletionRequirement } from '../routing/route-helpers.js'; export interface QueueEntry { id: string; @@ -49,6 +50,8 @@ export interface QueueEntry { callerTraceContext?: CallerTraceContext; /** Explicit A2A trigger message for stream reply threading. */ a2aTriggerMessageId?: string; + /** F257 LI-001: invocation completion contract that must survive busy-queue dispatch. */ + completionRequirement?: CompletionRequirement; /** F220 2a: ID of the primary queue entry that batched this sibling via collectUserBatch. * Set by markProcessingById when called from executeEntry's batch collection. * Used by zombie convergence to roll back batch siblings after the primary is removed. */ @@ -187,6 +190,9 @@ export class InvocationQueue { if (input.sourceCategory && !existing.sourceCategory) { existing.sourceCategory = input.sourceCategory; } + if (input.completionRequirement && !existing.completionRequirement) { + existing.completionRequirement = input.completionRequirement; + } } const position = q.findIndex((entry) => entry.id === existing.id); return { @@ -228,6 +234,7 @@ export class InvocationQueue { suggestedSkill: input.suggestedSkill, callerTraceContext: input.callerTraceContext, a2aTriggerMessageId: input.a2aTriggerMessageId, + completionRequirement: input.completionRequirement, position: undefined, }; q.push(entry); diff --git a/packages/api/src/domains/cats/services/agents/invocation/QueueProcessor.ts b/packages/api/src/domains/cats/services/agents/invocation/QueueProcessor.ts index f3145a7ec7..e16f7b2f4f 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/QueueProcessor.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/QueueProcessor.ts @@ -1563,6 +1563,7 @@ export class QueueProcessor { // #949 P1-1: Connector-sourced queue entries have no ball-pass expectation. // A2A/agent entries still get the verdict-pass handoff guard. verdictPassWarningEnabled: entry.source !== 'connector', + ...(entry.completionRequirement ? { completionRequirement: entry.completionRequirement } : {}), }, )) { if (controller.signal.aborted) { diff --git a/packages/api/src/domains/cats/services/agents/invocation/StartupReconciler.ts b/packages/api/src/domains/cats/services/agents/invocation/StartupReconciler.ts index f7114d8686..990970fbbf 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/StartupReconciler.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/StartupReconciler.ts @@ -239,6 +239,7 @@ export class StartupReconciler { if (messageStore) { try { const stored = await messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, // sol R3 P1-1 threadId, userId, catId: null, diff --git a/packages/api/src/domains/cats/services/agents/providers/BgTranscriptEventConsumer.ts b/packages/api/src/domains/cats/services/agents/providers/BgTranscriptEventConsumer.ts index 3ba58a2c91..c415ebbe62 100644 --- a/packages/api/src/domains/cats/services/agents/providers/BgTranscriptEventConsumer.ts +++ b/packages/api/src/domains/cats/services/agents/providers/BgTranscriptEventConsumer.ts @@ -96,6 +96,18 @@ export function transcriptEntriesToAgentMessages( if (result == null) continue; if (Array.isArray(result)) out.push(...result); else out.push(result); + continue; + } + + // LI-005: user entries contain tool_result content blocks (MCP execution + // results). Feed through transformClaudeEvent which bridges them to tool_result + // AgentMessages with toolResultStatus — needed for durable trigger classification. + // Same shape as -p NDJSON `user` events (content[].type === 'tool_result'). + if (entry.type === 'user') { + const result = transformClaudeEvent(entry, catId, state); + if (result == null) continue; + if (Array.isArray(result)) out.push(...result); + else out.push(result); } // Skip everything else (produce no user-facing AgentMessage): diff --git a/packages/api/src/domains/cats/services/agents/providers/HookSidechannelConsumer.ts b/packages/api/src/domains/cats/services/agents/providers/HookSidechannelConsumer.ts index 7d0df1a807..38fdc3d84f 100644 --- a/packages/api/src/domains/cats/services/agents/providers/HookSidechannelConsumer.ts +++ b/packages/api/src/domains/cats/services/agents/providers/HookSidechannelConsumer.ts @@ -11,7 +11,8 @@ * * Design decisions (from Fable-5 spike b570d6148 + KD-7): * - Stop event → text AgentMessage (last_assistant_message = full reply) - * - PostToolUse → tool_use AgentMessage (tool step visibility) + * - PostToolUse → tool_use + tool_result AgentMessages (tool step visibility + LI-005 durable trigger) + * - PostToolUseFailure → tool_result(error) AgentMessage (LI-005: failure path bridge) * - Stop = terminal signal (replaces transcript turn_duration detection) * - session_id from hook events (backup for transcript-watch) * - No usage/token data from hooks — accepted degradation @@ -27,11 +28,26 @@ export interface HookConsumerOptions { catId: CatId; } +/** + * Normalize tool_response from hook events to a string for downstream parsing. + * PostToolUse tool_response shapes vary by tool: + * - Read: `{type:'text', file:{content:'...', totalLines:50}}` + * - Bash: `{stdout:'...'}` + * - MCP: `'{"status":"ok",...}'` (string) or structured object + * - Missing: `undefined` + */ +function normalizeToolResponse(raw: unknown): string | undefined { + if (raw == null) return undefined; + if (typeof raw === 'string') return raw; + if (typeof raw === 'object') return JSON.stringify(raw); + return String(raw); +} + /** * Transform hook sidecar entries to AgentMessages. * * Pure function — no I/O, no state. Safe for incremental tailing. - * Only Stop and PostToolUse are recognized; unknown events are skipped. + * Stop, PostToolUse, and PostToolUseFailure are recognized; unknown events are skipped. */ export function hookEntriesToAgentMessages(entries: unknown[], options: HookConsumerOptions): AgentMessage[] { const { catId } = options; @@ -55,6 +71,7 @@ export function hookEntriesToAgentMessages(entries: unknown[], options: HookCons if (hookName === 'PostToolUse') { if (typeof entry.tool_name !== 'string') continue; + const toolUseId = typeof entry.tool_use_id === 'string' ? entry.tool_use_id : undefined; out.push({ type: 'tool_use', catId, @@ -62,9 +79,40 @@ export function hookEntriesToAgentMessages(entries: unknown[], options: HookCons toolInput: (typeof entry.tool_input === 'object' && entry.tool_input !== null ? entry.tool_input : {}) as Record, - toolUseId: typeof entry.tool_use_id === 'string' ? entry.tool_use_id : undefined, + toolUseId, timestamp: Date.now(), }); + // LI-005: emit tool_result for durable trigger classification. + // PostToolUse fires on successful tool completion (per cc hook contract). + // tool_response shape varies by tool: string, object, or array. + // Normalize to string so classifyDurableTriggerResult Level 2 can parse. + const resultMsg: AgentMessage = { + type: 'tool_result', + catId, + content: normalizeToolResponse(entry.tool_response), + timestamp: Date.now(), + toolResultStatus: 'ok', + }; + if (toolUseId) resultMsg.toolUseId = toolUseId; + out.push(resultMsg); + continue; + } + + // LI-005: PostToolUseFailure → tool_result(error) for failure path. + // cc fires PostToolUseFailure on tool execution failure; PostToolUse is + // success-only. Registering both ensures confirmedCallbackToolNames + // correctly excludes failed durable triggers. + if (hookName === 'PostToolUseFailure') { + const toolUseId = typeof entry.tool_use_id === 'string' ? entry.tool_use_id : undefined; + const resultMsg: AgentMessage = { + type: 'tool_result', + catId, + content: normalizeToolResponse(entry.tool_response), + timestamp: Date.now(), + toolResultStatus: 'error', + }; + if (toolUseId) resultMsg.toolUseId = toolUseId; + out.push(resultMsg); } // Unknown hook event names — silently skip diff --git a/packages/api/src/domains/cats/services/agents/providers/claude-ndjson-parser.ts b/packages/api/src/domains/cats/services/agents/providers/claude-ndjson-parser.ts index 01d75185e8..53cac14f02 100644 --- a/packages/api/src/domains/cats/services/agents/providers/claude-ndjson-parser.ts +++ b/packages/api/src/domains/cats/services/agents/providers/claude-ndjson-parser.ts @@ -5,9 +5,12 @@ */ import type { CatId } from '@cat-cafe/shared'; -import type { AgentMessage, TokenUsage } from '../../types.js'; +import type { AgentMessage } from '../../types.js'; import { extractClaudeMcpStatusSnapshot } from './claude-mcp-status.js'; +// Re-export for backward compatibility (extracted to claude-usage.ts for 350-line limit) +export { extractClaudeUsage } from './claude-usage.js'; + /** * Transform a raw Claude CLI NDJSON event into AgentMessage(s). * Returns null to skip events we don't care about (system/hook, result/success). @@ -285,6 +288,39 @@ export function transformClaudeEvent( }; } + // LI-005: user turn → tool_result bridge (MCP execution results). + // Claude CLI executes MCP tools internally; results appear as user-turn + // content blocks with is_error for success/failure classification. + if (e.type === 'user') { + const blocks = (e.message as Record | undefined)?.content; + if (!Array.isArray(blocks)) return null; + const msgs: AgentMessage[] = []; + for (const raw of blocks) { + if (typeof raw !== 'object' || raw === null) continue; + const b = raw as Record; + if (b.type !== 'tool_result') continue; + // content may be string or [{type:'text',text:'...'}] + let text: string | undefined; + if (typeof b.content === 'string') text = b.content; + else if (Array.isArray(b.content)) { + text = (b.content as Array>) + .filter((c) => c.type === 'text' && typeof c.text === 'string') + .map((c) => c.text as string) + .join(''); + } + const msg: AgentMessage = { + type: 'tool_result', + catId, + content: text, + timestamp: Date.now(), + toolResultStatus: b.is_error === true ? 'error' : 'ok', + }; + if (typeof b.tool_use_id === 'string') msg.toolUseId = b.tool_use_id; + msgs.push(msg); + } + return msgs.length > 0 ? msgs : null; + } + // result/success, system/hook, etc. → skip return null; } @@ -295,42 +331,4 @@ export function isResultErrorEvent(event: unknown): boolean { return e.type === 'result' && (e.is_error === true || e.subtype !== 'success'); } -/** F8: Extract token usage from Claude result/success event. - * Normalises inputTokens to total input (new + cache_read + cache_creation) - * so that the semantics match Codex/OpenAI where inputTokens = total. */ -export function extractClaudeUsage(e: Record): TokenUsage { - const usage = (e.usage ?? {}) as Record; - const result: TokenUsage = {}; - const rawInput = typeof usage.input_tokens === 'number' ? usage.input_tokens : 0; - const cacheRead = typeof usage.cache_read_input_tokens === 'number' ? usage.cache_read_input_tokens : 0; - const cacheCreate = typeof usage.cache_creation_input_tokens === 'number' ? usage.cache_creation_input_tokens : 0; - const totalInput = rawInput + cacheRead + cacheCreate; - if (totalInput > 0) result.inputTokens = totalInput; - if (typeof usage.output_tokens === 'number') result.outputTokens = usage.output_tokens; - if (cacheRead > 0) result.cacheReadTokens = cacheRead; - if (cacheCreate > 0) result.cacheCreationTokens = cacheCreate; - if (typeof e.total_cost_usd === 'number') result.costUsd = e.total_cost_usd; - if (typeof e.duration_ms === 'number') result.durationMs = e.duration_ms; - if (typeof e.duration_api_ms === 'number') result.durationApiMs = e.duration_api_ms; - if (typeof e.num_turns === 'number') result.numTurns = e.num_turns; - - // F24: Extract context window capacity from modelUsage. - // Claude stream-json has emitted both `modelUsage` and `model_usage` in different versions. - const modelUsage = (e.modelUsage ?? e.model_usage) as Record> | undefined; - if (modelUsage) { - for (const data of Object.values(modelUsage)) { - const contextWindow = - typeof data.contextWindow === 'number' - ? data.contextWindow - : typeof data.context_window === 'number' - ? data.context_window - : undefined; - if (contextWindow != null) { - result.contextWindowSize = contextWindow; - break; - } - } - } - - return result; -} +// extractClaudeUsage moved to ./claude-usage.ts (350-line limit); re-exported above. diff --git a/packages/api/src/domains/cats/services/agents/providers/claude-usage.ts b/packages/api/src/domains/cats/services/agents/providers/claude-usage.ts new file mode 100644 index 0000000000..fdd2e3b49d --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/providers/claude-usage.ts @@ -0,0 +1,48 @@ +/** + * F8: Extract token usage from Claude result/success event. + * + * Normalises inputTokens to total input (new + cache_read + cache_creation) + * so that the semantics match Codex/OpenAI where inputTokens = total. + * + * Extracted from claude-ndjson-parser.ts to keep file under 350-line limit + * after LI-005 added the user → tool_result bridge. + */ + +import type { TokenUsage } from '../../types.js'; + +export function extractClaudeUsage(e: Record): TokenUsage { + const usage = (e.usage ?? {}) as Record; + const result: TokenUsage = {}; + const rawInput = typeof usage.input_tokens === 'number' ? usage.input_tokens : 0; + const cacheRead = typeof usage.cache_read_input_tokens === 'number' ? usage.cache_read_input_tokens : 0; + const cacheCreate = typeof usage.cache_creation_input_tokens === 'number' ? usage.cache_creation_input_tokens : 0; + const totalInput = rawInput + cacheRead + cacheCreate; + if (totalInput > 0) result.inputTokens = totalInput; + if (typeof usage.output_tokens === 'number') result.outputTokens = usage.output_tokens; + if (cacheRead > 0) result.cacheReadTokens = cacheRead; + if (cacheCreate > 0) result.cacheCreationTokens = cacheCreate; + if (typeof e.total_cost_usd === 'number') result.costUsd = e.total_cost_usd; + if (typeof e.duration_ms === 'number') result.durationMs = e.duration_ms; + if (typeof e.duration_api_ms === 'number') result.durationApiMs = e.duration_api_ms; + if (typeof e.num_turns === 'number') result.numTurns = e.num_turns; + + // F24: Extract context window capacity from modelUsage. + // Claude stream-json has emitted both `modelUsage` and `model_usage` in different versions. + const modelUsage = (e.modelUsage ?? e.model_usage) as Record> | undefined; + if (modelUsage) { + for (const data of Object.values(modelUsage)) { + const contextWindow = + typeof data.contextWindow === 'number' + ? data.contextWindow + : typeof data.context_window === 'number' + ? data.context_window + : undefined; + if (contextWindow != null) { + result.contextWindowSize = contextWindow; + break; + } + } + } + + return result; +} diff --git a/packages/api/src/domains/cats/services/agents/providers/l0-compiler.ts b/packages/api/src/domains/cats/services/agents/providers/l0-compiler.ts index 551e3831fd..8c273d13d7 100644 --- a/packages/api/src/domains/cats/services/agents/providers/l0-compiler.ts +++ b/packages/api/src/domains/cats/services/agents/providers/l0-compiler.ts @@ -21,13 +21,25 @@ */ import { spawn as nodeSpawn } from 'node:child_process'; -import { existsSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveProfileDir } from '../../profile/profile-dir.js'; const SCRIPT_BASENAME = 'compile-system-prompt-l0.mjs'; +/** + * F257 #2 — one L-series (L1-L7) segment as rendered by the actual L0 compiler. + * The raw per-segment content is the authoritative artifact the provider delivers; + * hash/token/version formatting is derived downstream (see l0-manifest-trace.ts). + */ +export interface L0SegmentContent { + segmentId: string; + content: string; +} + // ── L0 cache ──────────────────────────────────────────────────────── // The compiled L0 depends on static inputs (shared-rules.md, cat config, // teammate roster) that don't change during a session. Caching avoids @@ -35,6 +47,12 @@ const SCRIPT_BASENAME = 'compile-system-prompt-l0.mjs'; // startup via warmL0Cache() and invalidated on hot-reload via clearL0Cache(). const l0Cache = new Map(); +// F257 #2 — per-segment L1-L7 manifest, populated in LOCKSTEP with l0Cache inside +// doCompileL0() (same subprocess, same generation guard, cleared together). This +// lets the injection trace be sourced from the SAME compiled artifact the provider +// delivers, instead of an out-of-band reconstruction that can diverge. +const l0ManifestCache = new Map(); + // In-flight Promise dedup — Phase G AC-G10 (砚砚 Design Gate position 1). // Without this, two concurrent calls on a cold cache (e.g. invoke provider // + Prompt X-Ray capture inside the same invocation hot path) both spawn @@ -70,6 +88,7 @@ function isL0GenerationCurrent(catId: string, generation: { global: number; cat: export function clearL0Cache(catId?: string): void { if (catId) { l0Cache.delete(catId); + l0ManifestCache.delete(catId); bumpL0Generation(catId); // Also drop any in-flight promise — next call will re-spawn fresh. The // generation guard prevents the older promise from repopulating l0Cache @@ -77,6 +96,7 @@ export function clearL0Cache(catId?: string): void { l0InflightPromises.delete(catId); } else { l0Cache.clear(); + l0ManifestCache.clear(); bumpL0Generation(); l0InflightPromises.clear(); } @@ -228,7 +248,20 @@ async function doCompileL0( // write (routes) MUST resolve identically or the nurturing loop silently breaks (a primer // written to one path while the injector reads another). const profileDir = resolveProfileDir(cwd, scriptPath); - const args = [scriptPath, '--cat', catId, '--profile-dir', profileDir, ...(outPath ? ['--out', outPath] : [])]; + // F257 #2: always request the per-segment L1-L7 manifest to a temp file so the + // injection trace can be sourced from this exact compiled artifact. Orthogonal to + // --out; the string compile stays authoritative + fail-closed. + const manifestPath = join(tmpdir(), `cat-cafe-l0-manifest-${catId}-${randomUUID()}.json`); + const args = [ + scriptPath, + '--cat', + catId, + '--profile-dir', + profileDir, + '--manifest-out', + manifestPath, + ...(outPath ? ['--out', outPath] : []), + ]; const stdout = await new Promise((resolvePromise, rejectPromise) => { const child = spawnFn(process.execPath, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] }); @@ -271,8 +304,56 @@ async function doCompileL0( } } + // F257 #2: read the per-segment manifest (best-effort — a manifest failure must + // NEVER fail the critical fail-closed string compile above). An empty manifest is a + // visible "L not observed" signal downstream, not a silent healthy-zero. + const manifest = readL0Manifest(manifestPath); + if (isL0GenerationCurrent(catId, compileGeneration)) { l0Cache.set(catId, result); + l0ManifestCache.set(catId, manifest); } return result; } + +/** Parse + clean up the temp L-segment manifest written by the compiler. */ +function readL0Manifest(manifestPath: string): L0SegmentContent[] { + try { + if (!existsSync(manifestPath)) return []; + const parsed: unknown = JSON.parse(readFileSync(manifestPath, 'utf8')); + if (!Array.isArray(parsed)) return []; + return parsed + .filter( + (e): e is { id: string; content: string } => + !!e && + typeof e === 'object' && + typeof (e as { id?: unknown }).id === 'string' && + typeof (e as { content?: unknown }).content === 'string', + ) + .map((e) => ({ segmentId: e.id, content: e.content })); + } catch { + return []; + } finally { + try { + if (existsSync(manifestPath)) unlinkSync(manifestPath); + } catch { + /* temp cleanup best-effort */ + } + } +} + +/** + * F257 #2 — get the per-segment L1-L7 manifest for a cat, sourced from the SAME + * compiled artifact the provider delivers. Cache-first; on a cold cache it triggers + * the shared subprocess compile (which populates both string + manifest caches under + * the generation guard), so the provider's later compile is a cache hit — no + * redundant work. Returns [] when the compile produced no manifest (visible signal). + */ +export async function getL0ManifestViaSubprocess(options: CompileL0Options): Promise { + const cached = l0ManifestCache.get(options.catId); + if (cached) return cached; + // Manifest cold ⇒ string cold too (they are set together in doCompileL0), so this + // actually compiles rather than hitting compileL0ViaSubprocess's string cache-first. + await compileL0ViaSubprocess(options); + return l0ManifestCache.get(options.catId) ?? []; +} diff --git a/packages/api/src/domains/cats/services/agents/providers/pty/hook-setup.ts b/packages/api/src/domains/cats/services/agents/providers/pty/hook-setup.ts index beb6588970..0b4949f439 100644 --- a/packages/api/src/domains/cats/services/agents/providers/pty/hook-setup.ts +++ b/packages/api/src/domains/cats/services/agents/providers/pty/hook-setup.ts @@ -96,6 +96,10 @@ fi hooks: { Stop: [hookEntry(scriptPath)], PostToolUse: postToolUseHooks, + // LI-005: capture tool execution failures for durable trigger classification. + // PostToolUseFailure fires when a tool call fails; HookSidechannelConsumer bridges + // it as tool_result(error) so failed hold_ball doesn't suppress void_ack hint. + PostToolUseFailure: [hookEntry(scriptPath)], }, }; writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8'); diff --git a/packages/api/src/domains/cats/services/agents/routing/AgentRouter.ts b/packages/api/src/domains/cats/services/agents/routing/AgentRouter.ts index 0995d1604b..f6da93163d 100644 --- a/packages/api/src/domains/cats/services/agents/routing/AgentRouter.ts +++ b/packages/api/src/domains/cats/services/agents/routing/AgentRouter.ts @@ -40,6 +40,7 @@ import type { TranscriptWriter } from '../../session/TranscriptWriter.js'; import { DeliveryCursorStore } from '../../stores/ports/DeliveryCursorStore.js'; import type { IDraftStore } from '../../stores/ports/DraftStore.js'; import type { IMessageStore } from '../../stores/ports/MessageStore.js'; +import { routedProvenance } from '../../stores/ports/MessageStore.js'; import type { ISessionChainStore } from '../../stores/ports/SessionChainStore.js'; import type { ITaskStore } from '../../stores/ports/TaskStore.js'; import type { IThreadStore, ThreadRoutingPolicyV1, ThreadRoutingScope } from '../../stores/ports/ThreadStore.js'; @@ -53,7 +54,9 @@ import type { AgentRegistry } from '../registry/AgentRegistry.js'; import type { PersistenceContext, RouteOptions, RouteStrategyDeps } from '../routing/route-helpers.js'; import { routeParallel } from '../routing/route-parallel.js'; import { routeSerial } from '../routing/route-serial.js'; -import { resolveCatTarget } from './cat-target-resolver.js'; +import { buildAmbiguousCandidates, groupRoutingTokenHolders, resolveCatTarget } from './cat-target-resolver.js'; +import { type RoutingAttemptBatch, RoutingAttemptCollector, type RoutingTokenSpan } from './routing-attempt.js'; +import { normalizeSpeechMentionsWithMap } from './speech-mention-map.js'; const log = createModuleLogger('agent-router'); const routeTracer = trace.getTracer('cat-cafe-api', '0.1.0'); @@ -109,6 +112,12 @@ interface ParsedMention { interface MentionPattern { pattern: string; catId: CatId; + /** + * F257 #1: all cats holding this pattern when it is shared by >1 cat. + * A populated list (length > 1) makes the token ambiguous — routing refuses + * to guess and emits mention_ambiguous instead of resolving to `catId`. + */ + contenders?: readonly CatId[]; } type MarkdownMentionMarker = '*' | '_'; @@ -383,12 +392,83 @@ function hasDomainSuffixedMentionPatternAt(message: string, pos: number, pattern }); } +/** + * F257 V1 draft wiring (T-A §3.4 parserMode=user). Drafts ride alongside the + * existing routing effects; span-level dedup in the collector makes re-visits + * across scan passes merge silently (traversal artifact, outcome unchanged). + */ +interface UserMentionDraftContext { + readonly collector: RoutingAttemptCollector; + /** Speech pass only: map scan-text spans back to raw (lowercased) message coordinates. */ + readonly mapSpan?: (span: RoutingTokenSpan) => RoutingTokenSpan; +} + +interface UserMentionDraftRef { + readonly collector: RoutingAttemptCollector; + readonly span: RoutingTokenSpan; + readonly token: string; +} + +function buildUserMentionDraft( + message: string, + position: number, + entry: MentionPattern, + ctx: UserMentionDraftContext | undefined, +): UserMentionDraftRef | undefined { + if (!ctx) return undefined; + const end = matchMentionPatternEnd(message, position, entry.pattern) ?? position + entry.pattern.length; + const scanSpan = { start: position, end }; + const span = ctx.mapSpan ? ctx.mapSpan(scanSpan) : scanSpan; + return { collector: ctx.collector, span, token: message.slice(position, end) }; +} + +/** + * Unknown-handle continuation charset (T-A user unknown_token row): Unicode + * letters/digits + ASCII `_.-`. sol R1 P1-2: the previous ASCII-only set + * dropped CJK unknown tokens with zero trace. `.`/`-` stay INSIDE the handle + * (unlike the a2a boundary set) — domain-shape discrimination + * (DOMAIN_LIKE_UNKNOWN_HANDLE_RE) needs the full dotted handle. + */ +const USER_HANDLE_CONTINUATION_RE = /^[\p{L}\p{N}_.-]+/u; + +/** `@handle` token at pos using the unknown-handle continuation charset. */ +function takeHandleToken(message: string, position: number): { handle: string; span: RoutingTokenSpan } | null { + const handle = message.slice(position + 1).match(USER_HANDLE_CONTINUATION_RE)?.[0]; + if (!handle) return null; + return { handle, span: { start: position, end: position + 1 + handle.length } }; +} + +/** + * F257 #1 (dev-628ea4d1): a matched pattern held by >1 cat is REFUSED, not + * resolved — emit an 'ambiguous' attempt draft plus one mention_ambiguous + * warning per distinct pattern, with each holder's unambiguous handle. + */ +function recordAmbiguousMention( + matched: MentionPattern, + position: number, + message: string, + routingWarnings: CatRoutingError[], + draftCtx?: UserMentionDraftContext, +): void { + const draft = buildUserMentionDraft(message, position, matched, draftCtx); + draft?.collector.add(draft.span, draft.token, 'ambiguous'); + const alreadyWarned = routingWarnings.some((w) => w.kind === 'mention_ambiguous' && w.mention === matched.pattern); + if (!alreadyWarned) { + routingWarnings.push({ + kind: 'mention_ambiguous', + mention: matched.pattern, + candidates: buildAmbiguousCandidates(matched.contenders ?? []), + }); + } +} + function recordRouteLineMentions( message: string, patterns: readonly MentionPattern[], seenCats: Set, mentions: ParsedMention[], routingWarnings: CatRoutingError[], + draftCtx?: UserMentionDraftContext, ): void { const excluded = buildMentionExclusionSpans(message); forEachRouteLineMentionCandidate(message, (_line, lineOffset, candidate) => { @@ -398,7 +478,14 @@ function recordRouteLineMentions( const matched = findMentionPatternAt(message, position, patterns, (end) => skipClosingRouteMarkdownMarkers(message, end, openingMarkers), ); - if (matched) recordResolvedMention(matched.catId, position, seenCats, mentions, routingWarnings); + if (matched) { + if (matched.contenders && matched.contenders.length > 1) { + recordAmbiguousMention(matched, position, message, routingWarnings, draftCtx); + return; + } + const draft = buildUserMentionDraft(message, position, matched, draftCtx); + recordResolvedMention(matched.catId, position, seenCats, mentions, routingWarnings, draft); + } }); } @@ -408,10 +495,12 @@ function recordResolvedMention( seenCats: Set, mentions: ParsedMention[], routingWarnings: CatRoutingError[], + draft?: UserMentionDraftRef, ): void { const key = catId as string; const resolved = resolveCatTarget(key); if ('error' in resolved) { + draft?.collector.add(draft.span, draft.token, 'disabled_cat', catId); if (!seenCats.has(key)) { seenCats.add(key); routingWarnings.push(resolved.error); @@ -420,11 +509,15 @@ function recordResolvedMention( } if (!seenCats.has(key)) { + draft?.collector.add(draft.span, draft.token, 'resolved', catId); seenCats.add(key); mentions.push({ catId, position }); return; } + // Distinct span, same target — T-A duplicate row. A same-span re-visit is + // dropped by the collector before this outcome can overwrite the original. + draft?.collector.add(draft.span, draft.token, 'duplicate', catId); const existing = mentions.find((mention) => String(mention.catId) === key); if (existing && position < existing.position) { existing.position = position; @@ -436,12 +529,18 @@ function recordUnknownMentionWarning( position: number, seenCats: Set, routingWarnings: CatRoutingError[], + draftCtx?: UserMentionDraftContext, ): void { - const handle = message.slice(position + 1).match(/^([a-z0-9_.-]+)/)?.[1]; - if (!handle) return; - if (DOMAIN_LIKE_UNKNOWN_HANDLE_RE.test(handle)) return; - const key = `@unknown:${handle}`; - const resolved = resolveCatTarget(handle); + const token = takeHandleToken(message, position); + if (!token) return; + if (DOMAIN_LIKE_UNKNOWN_HANDLE_RE.test(token.handle)) { + // Domain-shaped handle — same skip semantics as the pattern+suffix path (T-A domain_suffixed_skip row). + draftCtx?.collector.add(token.span, message.slice(token.span.start, token.span.end), 'domain_suffixed_skip'); + return; + } + draftCtx?.collector.add(token.span, message.slice(token.span.start, token.span.end), 'unknown_token'); + const key = `@unknown:${token.handle}`; + const resolved = resolveCatTarget(token.handle); if ('error' in resolved && !seenCats.has(key)) { seenCats.add(key); routingWarnings.push(resolved.error); @@ -597,6 +696,8 @@ export interface AgentRouterOptions { freshnessStateStore?: import('../../freshness/FreshnessInvocationStateStore.js').FreshnessInvocationStateStore; /** F237 Phase 2 (AC-P2-8): Injection trace store for pipeline observability */ injectionTraceStore?: import('../../../../prompt-hooks/InjectionTraceStore.js').InjectionTraceStore; + /** F257 Phase A (Line B): Guard rejection event log — fail-open observation layer */ + guardRejectionLog?: import('../../../../../infrastructure/harness-eval/GuardRejectionEventLog.js').GuardRejectionEventLog; } /** @@ -669,6 +770,8 @@ export class AgentRouter { private freshnessStateStore?: import('../../freshness/FreshnessInvocationStateStore.js').FreshnessInvocationStateStore; /** F237 Phase 2 (AC-P2-8) */ private injectionTraceStore?: import('../../../../prompt-hooks/InjectionTraceStore.js').InjectionTraceStore; + /** F257 Phase A (Line B): Guard rejection event log */ + private guardRejectionLog?: import('../../../../../infrastructure/harness-eval/GuardRejectionEventLog.js').GuardRejectionEventLog; private speechMentionRe: RegExp; /** @@ -779,6 +882,7 @@ export class AgentRouter { this.freshnessReinvokeCheck = options.freshnessReinvokeCheck; this.freshnessStateStore = options.freshnessStateStore; this.injectionTraceStore = options.injectionTraceStore; + this.guardRejectionLog = options.guardRejectionLog; } refreshFromRegistry(agentRegistry: AgentRegistry): void { @@ -974,7 +1078,8 @@ export class AgentRouter { /** Normalize speech patterns like "at 布偶" → "@布偶" */ private normalizeSpeechMentions(message: string): string { - return message.replace(this.speechMentionRe, (_match, prefix: string, mention: string) => `${prefix}@${mention}`); + // Single implementation shared with the offset-mapped variant (F257 V1). + return normalizeSpeechMentionsWithMap(message, this.speechMentionRe).text; } /** @@ -982,17 +1087,26 @@ export class AgentRouter { * F182 KD-10: match-time resolver check (different patch from a2a-mentions pattern-build stage). * Raw variant returns ParsedMention[] with position info for order-aware merging. */ - private parseMentionsRaw(message: string): { mentions: ParsedMention[]; routing_warnings: CatRoutingError[] } { + private parseMentionsRaw(message: string): { + mentions: ParsedMention[]; + routing_warnings: CatRoutingError[]; + attemptBatch: RoutingAttemptBatch; + } { const lowerMessage = message.toLowerCase(); - const speechRouteMessage = this.normalizeSpeechMentions(message).toLowerCase(); + const speech = normalizeSpeechMentionsWithMap(message, this.speechMentionRe); + const speechRouteMessage = speech.text.toLowerCase(); - // 1. Collect all mentionPatterns → catId, sorted by length descending + // 1. Collect all mentionPatterns → catId, sorted by length descending. + // F257 #1: group by normalized pattern first — a pattern held by >1 cat stays + // matchable but carries `contenders` so the match site refuses to guess + // (mention_ambiguous) instead of longest-first silently picking one holder. const allPatterns: MentionPattern[] = []; - const allConfigs = catRegistry.getAllConfigs(); - for (const config of Object.values(allConfigs)) { - for (const pattern of config.mentionPatterns) { - allPatterns.push({ pattern: pattern.toLowerCase(), catId: config.id }); - } + for (const [patternKey, holders] of groupRoutingTokenHolders()) { + allPatterns.push( + holders.length === 1 + ? { pattern: patternKey, catId: holders[0] } + : { pattern: patternKey, catId: holders[0], contenders: holders }, + ); } allPatterns.sort((a, b) => b.pattern.length - a.pattern.length); // longest first @@ -1001,37 +1115,96 @@ export class AgentRouter { const mentions: ParsedMention[] = []; const seenCats = new Set(); const routing_warnings: CatRoutingError[] = []; + const collector = new RoutingAttemptCollector(); + const draftCtx: UserMentionDraftContext = { collector }; + const groupKeywords = this.buildGroupMentionKeywordPatterns(); // Route-line grammar handles markdown/list wrappers before the broader inline scan. - recordRouteLineMentions(lowerMessage, allPatterns, seenCats, mentions, routing_warnings); + recordRouteLineMentions(lowerMessage, allPatterns, seenCats, mentions, routing_warnings, draftCtx); // Explicit @mentions are user-authored route tokens and may appear anywhere in prose. forEachUserMentionCandidate(lowerMessage, (pos) => { const matched = findMentionPatternAt(lowerMessage, pos, allPatterns); if (matched) { - recordResolvedMention(matched.catId, pos, seenCats, mentions, routing_warnings); + if (matched.contenders && matched.contenders.length > 1) { + recordAmbiguousMention(matched, pos, lowerMessage, routing_warnings, draftCtx); + return; + } + const draft = buildUserMentionDraft(lowerMessage, pos, matched, draftCtx); + recordResolvedMention(matched.catId, pos, seenCats, mentions, routing_warnings, draft); return; } + // F257 T-A 改造④: group keywords are recognized at the draft layer first so + // they never fall through to unknown_token drafts (warnings unchanged). + const groupDrafted = this.draftGroupKeywordAt(lowerMessage, pos, groupKeywords, collector); // P2 (codex review 6949db49): an explicit @handle that matched NO registered cat is an // unknown handle (e.g. @kimi). Without this, parseAllMentions returns empty mentions + empty // warnings, so the caller silently falls back to the default cat with zero user feedback. - if (hasDomainSuffixedMentionPatternAt(lowerMessage, pos, allPatterns)) return; - recordUnknownMentionWarning(lowerMessage, pos, seenCats, routing_warnings); + if (hasDomainSuffixedMentionPatternAt(lowerMessage, pos, allPatterns)) { + if (!groupDrafted) { + const token = takeHandleToken(lowerMessage, pos); + if (token) + collector.add(token.span, lowerMessage.slice(token.span.start, token.span.end), 'domain_suffixed_skip'); + } + return; + } + recordUnknownMentionWarning(lowerMessage, pos, seenCats, routing_warnings, groupDrafted ? undefined : draftCtx); }); // Speech aliases like "at 砚砚" stay limited to route-line syntax; otherwise ordinary // prose such as "look at codex docs" would become an implicit route. if (speechRouteMessage !== lowerMessage) { - recordRouteLineMentions(speechRouteMessage, allPatterns, seenCats, mentions, routing_warnings); + recordRouteLineMentions(speechRouteMessage, allPatterns, seenCats, mentions, routing_warnings, { + collector, + mapSpan: speech.mapSpanToRaw, + }); } mentions.sort((a, b) => a.position - b.position); - return { mentions, routing_warnings }; + return { mentions, routing_warnings, attemptBatch: collector.finalize('user', 'lowercased_message') }; } - private parseMentions(message: string): { mentions: CatId[]; routing_warnings: CatRoutingError[] } { + /** F257 T-A group_keyword_skip row: draft-layer recognition, boundary rules identical to parseGroupMentions. */ + private draftGroupKeywordAt( + message: string, + position: number, + groupKeywords: readonly string[], + collector: RoutingAttemptCollector, + ): boolean { + for (const keyword of groupKeywords) { + if (!message.startsWith(keyword, position)) continue; + const charAfter = message[position + keyword.length]; + if (charAfter && !MENTION_TOKEN_BOUNDARY_RE.test(charAfter)) continue; + collector.add( + { start: position, end: position + keyword.length }, + message.slice(position, position + keyword.length), + 'group_keyword_skip', + ); + return true; + } + return false; + } + + /** Group keyword strings, longest-first — same constructible set as parseGroupMentions. */ + private buildGroupMentionKeywordPatterns(): string[] { + const keywords = ['@全体参与者', '@thread', '@本帖', '@全体', '@all']; + for (const [breedId, info] of this.collectBreedGroups()) { + keywords.push(`@全体${info.displayName}`, `@all-${breedId}`); + } + return keywords.map((keyword) => keyword.toLowerCase()).sort((a, b) => b.length - a.length); + } + + private parseMentions(message: string): { + mentions: CatId[]; + routing_warnings: CatRoutingError[]; + attemptBatch: RoutingAttemptBatch; + } { const raw = this.parseMentionsRaw(message); - return { mentions: raw.mentions.map((m) => m.catId), routing_warnings: raw.routing_warnings }; + return { + mentions: raw.mentions.map((m) => m.catId), + routing_warnings: raw.routing_warnings, + attemptBatch: raw.attemptBatch, + }; } /** @@ -1042,6 +1215,26 @@ export class AgentRouter { * P1 fix: uses token boundary check (same regex as parseMentions) to avoid * substring collisions like @allison→@all or @threadsafe→@thread. */ + /** Breed groups with at least one registered service — shared by group routing and draft classification. */ + private collectBreedGroups(): Map { + const allConfigs = catRegistry.getAllConfigs(); + const breedMap = new Map(); + for (const [catId, config] of Object.entries(allConfigs)) { + if (!config.breedId) continue; + if (!Object.hasOwn(this.services, catId)) continue; + const existing = breedMap.get(config.breedId); + if (existing) { + existing.catIds.push(catId as CatId); + } else { + breedMap.set(config.breedId, { + displayName: config.breedDisplayName ?? config.displayName, + catIds: [catId as CatId], + }); + } + } + return breedMap; + } + private async parseGroupMentions( message: string, threadId: string, @@ -1091,21 +1284,7 @@ export class AgentRouter { } // Breed-scoped patterns: @全体{displayName} and @all-{breedId} - const allConfigs = catRegistry.getAllConfigs(); - const breedMap = new Map(); - for (const [catId, config] of Object.entries(allConfigs)) { - if (!config.breedId) continue; - if (!Object.hasOwn(this.services, catId)) continue; - const existing = breedMap.get(config.breedId); - if (existing) { - existing.catIds.push(catId as CatId); - } else { - breedMap.set(config.breedId, { - displayName: config.breedDisplayName ?? config.displayName, - catIds: [catId as CatId], - }); - } - } + const breedMap = this.collectBreedGroups(); for (const [breedId, info] of breedMap) { const catIds = info.catIds; patterns.push({ pattern: `@全体${info.displayName}`, resolve: async () => this.filterRoutableCats(catIds) }); @@ -1162,7 +1341,7 @@ export class AgentRouter { private async parseAllMentions( message: string, threadId: string, - ): Promise<{ mentions: CatId[]; routing_warnings: CatRoutingError[] }> { + ): Promise<{ mentions: CatId[]; routing_warnings: CatRoutingError[]; attemptBatch: RoutingAttemptBatch }> { const groupResult = await this.parseGroupMentions(message, threadId); if (groupResult !== null) { // Position-aware union: merge individual mentions around group based on message position @@ -1199,6 +1378,9 @@ export class AgentRouter { return { mentions: [...before, ...groupResult.cats, ...after], routing_warnings: filteredWarnings, + // Group expansion targets are not @-parse attempts (group mention exits V1); + // the individual batch already carries the group keyword as group_keyword_skip. + attemptBatch: individual.attemptBatch, }; } return this.parseMentions(message); @@ -1210,8 +1392,13 @@ export class AgentRouter { * Does NOT mutate thread participants. */ private async peekTargets(message: string, threadId: string): Promise { - const { mentions: mentionedCats } = await this.parseAllMentions(message, threadId); + const parsed = await this.parseAllMentions(message, threadId); + const mentionedCats = parsed.mentions; if (mentionedCats.length > 0) return mentionedCats; + // F257 #1 (sol F3): an ambiguous-only message is an EXPLICIT route attempt the + // system refused to resolve — falling back to recent/default would dispatch a + // cat the author never addressed while the UI says "not routed". Zero targets. + if (parsed.routing_warnings.some((w) => w.kind === 'mention_ambiguous')) return []; if (this.threadStore) { const thread = await this.threadStore.get(threadId); @@ -1279,7 +1466,8 @@ export class AgentRouter { /** Resolve target cats and persist new mentions as thread participants */ private async resolveTargets(message: string, threadId: string): Promise { - const { mentions: mentionedCats } = await this.parseAllMentions(message, threadId); + const parsed = await this.parseAllMentions(message, threadId); + const mentionedCats = parsed.mentions; if (mentionedCats.length > 0) { if (this.threadStore) { @@ -1288,6 +1476,9 @@ export class AgentRouter { return mentionedCats; } + // F257 #1 (sol F3): ambiguous-only → zero targets, no fallback (see peekTargets) + if (parsed.routing_warnings.some((w) => w.kind === 'mention_ambiguous')) return []; + if (this.threadStore) { const thread = await this.threadStore.get(threadId); @@ -1392,6 +1583,7 @@ export class AgentRouter { ...(this.pendingRequestStore ? { pendingRequestStore: this.pendingRequestStore } : {}), ...(this.ballCustody ? { ballCustody: this.ballCustody } : {}), ...(this.injectionTraceStore ? { injectionTraceStore: this.injectionTraceStore } : {}), + ...(this.guardRejectionLog ? { guardRejectionLog: this.guardRejectionLog } : {}), }; } @@ -1404,7 +1596,13 @@ export class AgentRouter { message: string, threadId?: string, options?: { persist?: boolean }, - ): Promise<{ targetCats: CatId[]; intent: IntentResult; hasMentions: boolean; routing_warnings: CatRoutingError[] }> { + ): Promise<{ + targetCats: CatId[]; + intent: IntentResult; + hasMentions: boolean; + routing_warnings: CatRoutingError[]; + attemptBatch: RoutingAttemptBatch; + }> { const resolvedThreadId = threadId ?? DEFAULT_THREAD_ID; // Capture both valid mentions AND routing_warnings (for disabled/not-found cats). // routing_warnings lets callers (e.g. messages.ts) surface explicit feedback when @@ -1416,7 +1614,7 @@ export class AgentRouter { ? await this.resolveTargets(message, resolvedThreadId) : await this.peekTargets(message, resolvedThreadId); const intent = parseIntent(message, targetCats.length); - return { targetCats, intent, hasMentions, routing_warnings }; + return { targetCats, intent, hasMentions, routing_warnings, attemptBatch: allMentions.attemptBatch }; } /** @@ -1433,8 +1631,12 @@ export class AgentRouter { signal?: AbortSignal, ): AsyncIterable { const resolvedThreadId = threadId ?? DEFAULT_THREAD_ID; - const targetCats = await this.resolveTargets(message, resolvedThreadId); - const intent = parseIntent(message, targetCats.length); + // sol R1 P1-1: this legacy path also writes a user message — it must carry the + // parser's attemptBatch or the coverage cohort reports a producer gap. + // resolveTargetsAndIntent(persist:true) = resolveTargets + parseIntent (same calls). + const { targetCats, intent, attemptBatch } = await this.resolveTargetsAndIntent(message, resolvedThreadId, { + persist: true, + }); const strategy = intent.intent === 'ideate' && targetCats.length > 1 ? 'parallel' : 'serial'; const cleanMessage = stripIntentTags(message); @@ -1464,6 +1666,7 @@ export class AgentRouter { mentions: targetCats, timestamp: Date.now(), threadId: resolvedThreadId, + ...routedProvenance('user', attemptBatch), // F257 (T-A §3.4 / §4.5.1; sol R3 P1-1) ...(contentBlocks ? { contentBlocks } : {}), }); @@ -1591,6 +1794,8 @@ export class AgentRouter { /** #949 P2: Whether verdict-without-pass warning fires at route end. * true/undefined = warn (default). false = suppress for connector-sourced flows only. */ verdictPassWarningEnabled?: boolean; + /** F257 LI-001: opt-in completion contract for action-bearing wake invocations. */ + completionRequirement?: RouteOptions['completionRequirement']; /** F254 B3: Freshness re-invoke enqueue for routing layer consumption */ freshnessReinvokeEnqueue?: RouteOptions['freshnessReinvokeEnqueue']; }, @@ -1713,6 +1918,7 @@ export class AgentRouter { ...(options?.verdictPassWarningEnabled !== undefined ? { verdictPassWarningEnabled: options.verdictPassWarningEnabled } : {}), + ...(options?.completionRequirement ? { completionRequirement: options.completionRequirement } : {}), }; try { diff --git a/packages/api/src/domains/cats/services/agents/routing/a2a-ack-liveness.ts b/packages/api/src/domains/cats/services/agents/routing/a2a-ack-liveness.ts new file mode 100644 index 0000000000..7f9a2da86d --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/routing/a2a-ack-liveness.ts @@ -0,0 +1,185 @@ +/** + * LI-005 Phase 1 — A2A Ack Liveness Detection (接球执行触发存活性检测). + * + * 检测猫通过 A2A 接到球后(inline @mention 或 queue-dispatched),invocation 结束时 + * 既无路由出口(行首 @mention / @co-creator / structured routing)也无持久触发器 + * (hold_ball / register_scheduled_task / register_pr_tracking / + * register_issue_tracking / community_await_external),导致球静默死亡—— + * 无机制保证后续执行。 + * + * Phase 1 scope: detection + hint + observability(ball.void_ack 事件 + telemetry)。 + * Phase 2(structural rejection / auto-wake)待 Phase 1 收集数据后实施。 + * + * 声明-动作一致性检查的延伸:void-hold 查"说持球没做", + * ack-liveness 查"接了球没绑触发器"。 + * + * A2A 路径信号(isA2AInvocation): + * - inline serial: `directMessageFrom`(routeSerial a2aFrom map 中 catId) + * - queue-dispatched: `queueTriggerReplyTo`(derived from `a2aTriggerMessageId` in options) + * `a2aTriggerMessageId` 已确认为 **cat→cat 专属**——5 个赋值点全部在 A2A 路径 + * (callback-a2a-trigger.ts 3 处 + route-serial.ts inline/deferred 2 处), + * 0 个 operator/user/connector 路径设置此字段。operator 发起的 invocation + * 不会被误判为 A2A。详见 Fable ① 核验(2026-07-16 Explore agent 穷举确认)。 + * + * 纯函数、零 IO、可测。 + * + * @see void-hold-detect.ts — 同族守卫,模式参照 + * @see docs/features/assets/li005-ack-liveness/live-candidates-2026-07-14.md — LI-005 定义 + */ + +/** + * Tool names that constitute a "durable trigger" — calling any one of these + * means the cat bound a mechanism that will ensure future execution. + * + * Criteria: the tool MUST register a system mechanism (timer, webhook, cron) + * that will invoke the cat in the future. Pure bookkeeping tools (create_task) + * do NOT qualify — they create visible panel items but have no invokeTrigger + * or scheduled wake. + * + * Order doesn't matter (Set-based lookup). The list uses suffix matching + * to cover both `mcp__cat-cafe-collab__cat_cafe_hold_ball` and + * `cat_cafe_hold_ball` forms. + * + * Caller contract (all providers, per Sol R4): + * All providers now emit tool_result events — Claude CLI's user-turn + * tool_result content blocks are bridged in claude-ndjson-parser.ts (R4). + * Route-serial passes only confirmed-successful tool names via + * `classifyDurableTriggerResult` into `confirmedCallbackToolNames`. + * Failed tool calls (400/429/error) are excluded — the hint fires, + * which is the correct fail-closed behavior. + */ +const DURABLE_TRIGGER_SUFFIXES: readonly string[] = [ + 'cat_cafe_hold_ball', + 'cat_cafe_register_scheduled_task', + 'cat_cafe_register_pr_tracking', + 'cat_cafe_register_issue_tracking', + 'cat_cafe_community_await_external', +] as const; + +function hasDurableTriggerToolCall(toolNames: readonly string[]): boolean { + return toolNames.some((name) => DURABLE_TRIGGER_SUFFIXES.some((suffix) => name.endsWith(suffix))); +} + +function hasRoutingExit(input: { + lineStartMentions: readonly string[]; + structuredTargetCats: readonly string[]; + hasCoCreatorLineStartMention: boolean; +}): boolean { + if (input.lineStartMentions.length > 0) return true; + if (input.structuredTargetCats.length > 0) return true; + if (input.hasCoCreatorLineStartMention) return true; + return false; +} + +export interface AckLivenessInput { + /** True if this cat was invoked via A2A (@mention from another cat). */ + readonly isA2AInvocation: boolean; + /** + * Confirmed-successful durable trigger tool names only. + * All providers emit tool_result events (Claude CLI bridge added R4); + * route-serial classifies each via `classifyDurableTriggerResult` and + * only includes confirmed successes in `confirmedCallbackToolNames`. + */ + readonly toolNames: readonly string[]; + /** Line-start @mentions detected in the response text. */ + readonly lineStartMentions: readonly string[]; + /** + * Confirmed structured target cats from successful tool_results + * (post_message / cross_post_message). Unconfirmed tool_use inputs must NOT + * be used — a failed post_message should not suppress the hint (P2-2 fix). + */ + readonly structuredTargetCats: readonly string[]; + /** Whether the response text contains a co-creator line-start mention. */ + readonly hasCoCreatorLineStartMention: boolean; +} + +export interface AckLivenessEvaluation { + /** True iff the void-ack hint should fire. */ + readonly shouldEmit: boolean; + /** True if the invocation had any routing exit (@ / structured routing). */ + readonly hasRoutingExit: boolean; + /** True if the invocation called any durable trigger tool. */ + readonly hasDurableTrigger: boolean; +} + +/** + * Evaluate whether an A2A invocation ended without any durable trigger + * or routing exit — the ball effectively dies. + * + * Only fires when ALL of: + * 1. The cat was invoked via A2A (inline @mention or queue-dispatched) + * 2. No routing exit exists (no @mention, no structured routing, no @co-creator) + * 3. No durable trigger was bound (no hold_ball, register_scheduled_task, etc.) + * + * Non-A2A invocations (user-initiated) always return shouldEmit=false + * because the user is watching and can re-invoke manually. + */ +export function evaluateAckLiveness(input: AckLivenessInput): AckLivenessEvaluation { + if (!input.isA2AInvocation) { + return { shouldEmit: false, hasRoutingExit: false, hasDurableTrigger: false }; + } + + const routing = hasRoutingExit(input); + const trigger = hasDurableTriggerToolCall(input.toolNames); + + return { + shouldEmit: !routing && !trigger, + hasRoutingExit: routing, + hasDurableTrigger: trigger, + }; +} + +/** + * Classify whether a tool_result for a durable trigger represents confirmed + * success. Two-level check per Sol R3 P1: + * + * 1. Structural `toolResultStatus` (set by provider event transformers: + * Codex maps item.status; Gemini hardcodes 'ok'; CatAgent maps status). + * 'ok' → confirmed success; 'error' → confirmed failure. + * + * 2. Tool-specific JSON body parsing (fallback when toolResultStatus is + * undefined or 'unknown'). Each durable trigger returns a different + * success shape: + * - hold_ball: {status: 'ok', held: true, ...} + * - register_pr_tracking: {status: 'ok', threadId, task} + * - register_issue_tracking: {status: 'ok', threadId, task} + * - register_scheduled_task: {success: true, task: {...}} + * - community_await_external:{state: 'awaiting_external', ...} + * + * Fail-closed: unknown shapes or parse errors → not confirmed (the hint + * fires, which is safer than suppressing a genuine void ack). + * + * Pure function, zero IO. + */ +export function classifyDurableTriggerResult( + toolName: string, + resultContent: string | undefined, + toolResultStatus: 'ok' | 'error' | 'unknown' | undefined, +): boolean { + // Only classify durable trigger tools + if (!DURABLE_TRIGGER_SUFFIXES.some((suffix) => toolName.endsWith(suffix))) return false; + + // Level 1: structural status from provider transformer + if (toolResultStatus === 'ok') return true; + if (toolResultStatus === 'error') return false; + + // Level 2: tool-specific body parsing + if (!resultContent) return false; + try { + const jsonStart = resultContent.indexOf('{'); + if (jsonStart < 0) return false; + const parsed = JSON.parse(resultContent.slice(jsonStart)) as Record; + // hold_ball / register_pr_tracking / register_issue_tracking + if (parsed.status === 'ok' || parsed.status === 'duplicate') return true; + // register_scheduled_task + if (parsed.success === true) return true; + // community_await_external + if (parsed.state === 'awaiting_external') return true; + // Explicit error markers + if (parsed.isError === true || parsed.error) return false; + // Unknown shape → fail-closed + return false; + } catch { + return false; + } +} diff --git a/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts b/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts index b769b7cfef..6e9cbd911c 100644 --- a/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts +++ b/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts @@ -14,7 +14,14 @@ import type { CatId, CatRoutingError } from '@cat-cafe/shared'; import { catRegistry } from '@cat-cafe/shared'; import { isCatAvailable } from '../../../../../config/cat-config-loader.js'; -import { resolveCatTarget } from './cat-target-resolver.js'; +import { buildAmbiguousCandidates, groupRoutingTokenHolders, resolveCatTarget } from './cat-target-resolver.js'; +import { + isMetricEligibleOutcome, + type RoutingAttemptBatch, + RoutingAttemptCollector, + type RoutingAttemptOutcome, + type RoutingTokenSpan, +} from './routing-attempt.js'; /** Max A2A chain depth, configurable via env (read at call time for hot-reload) */ export function getMaxA2ADepth(): number { @@ -34,6 +41,14 @@ const HANDLE_BOUNDARY_PATTERN = String.raw`(?=$|[\s,.:;!?()\[\]{}<>,。!? interface MentionPatternEntry { readonly catId: CatId; readonly pattern: string; + /** F257 T-A 改造①: self patterns participate in matching, flagged instead of removed. */ + readonly isSelf?: boolean; + /** + * F257 #1: all holders when the pattern is shared by >1 cat — the token is + * ambiguous and evaluateA2AToken refuses to resolve it (no guessing, not + * even "is it me?": ambiguity beats self_excluded). + */ + readonly contenders?: readonly CatId[]; } function escapeRegExp(value: string): string { @@ -62,6 +77,8 @@ export interface A2AMentionAnalysis { readonly mentions: CatId[]; /** F182: routing errors for disabled cats detected in text @ parsing */ readonly routing_warnings: CatRoutingError[]; + /** F257 V1: per-token routing attempt drafts — semantics per T-A (§3.4). */ + readonly attemptBatch: RoutingAttemptBatch; } /** #417: Inline @mention paired with action words — missed handoff candidate. */ @@ -81,80 +98,200 @@ export function parseA2AMentions(text: string, currentCatId?: CatId): CatId[] { } export function analyzeA2AMentions(text: string, currentCatId?: CatId): A2AMentionAnalysis { - if (!text) return { mentions: [], routing_warnings: [] }; + const collector = new RoutingAttemptCollector(); + if (!text) { + return { mentions: [], routing_warnings: [], attemptBatch: collector.finalize('a2a', 'a2a_normalized') }; + } // 1. Strip fenced code blocks const stripped = text.replace(/```[\s\S]*?```/g, ''); - // F32-a: read from catRegistry (.cat-cafe/cat-catalog.json) - const allConfigs = catRegistry.getAllConfigs(); - // 2. Build patterns and sort longest-first to avoid prefix collisions // F182 KD-10: include ALL cats (including disabled) so patterns participate in matching; // availability is checked at match-time via resolveCatTarget, not here. + // F257 T-A 改造①: self patterns stay in the set (flagged) so self tokens are + // tokenized instead of aborting the line scan. + // F257 #1: group by normalized pattern (catRegistry via groupRoutingTokenHolders) — + // a multi-holder pattern stays matchable but carries `contenders` so + // evaluateA2AToken refuses to guess a target (ambiguity beats self-exclusion). const entries: MentionPatternEntry[] = []; - for (const [id, config] of Object.entries(allConfigs)) { - if (currentCatId && id === currentCatId) continue; // 4. Filter self (skip when cross-thread) - for (const pattern of config.mentionPatterns) { - entries.push({ catId: id as CatId, pattern: pattern.toLowerCase() }); - } + for (const [patternKey, holders] of groupRoutingTokenHolders()) { + const isSelf = currentCatId !== undefined && holders.length === 1 && holders[0] === currentCatId; + entries.push( + holders.length === 1 + ? { catId: holders[0], pattern: patternKey, isSelf } + : { catId: holders[0], pattern: patternKey, isSelf: false, contenders: holders }, + ); } entries.sort((a, b) => b.pattern.length - a.pattern.length); const normalizedText = repairLineStartMentionWhitespace(stripped, entries); // 3. Line-start matching with token boundary — always actionable (no keyword gate) - const found: CatId[] = []; - const seen = new Set(); - const routing_warnings: CatRoutingError[] = []; - const lines = normalizedText.split(/\r?\n/); - for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) { - const rawLine = lines[lineIndex]!; - if (found.length >= MAX_A2A_MENTION_TARGETS) break; // 5. Safety limit - - const leadingWs = rawLine.match(/^\s*/)?.[0].length ?? 0; - const normalized = rawLine.slice(leadingWs).toLowerCase().replace(LEADING_MARKDOWN_MENTION_PREFIX_RE, ''); - if (!normalized.startsWith('@')) { - continue; + const state: A2AScanState = { + entries, + found: [], + seen: new Set(), + routingWarnings: [], + collector, + capReached: false, + truncated: false, + }; + let lineStart = 0; + while (lineStart <= normalizedText.length) { + // split(/\r?\n/) semantics: lines end at \n, an immediately preceding \r joins the separator + const sepIndex = normalizedText.indexOf('\n', lineStart); + const lineEnd = + sepIndex < 0 + ? normalizedText.length + : sepIndex > lineStart && normalizedText[sepIndex - 1] === '\r' + ? sepIndex - 1 + : sepIndex; + scanA2ARouteLine(normalizedText.slice(lineStart, lineEnd), lineStart, state); + if (state.truncated || sepIndex < 0) break; + lineStart = sepIndex + 1; + } + + return { + mentions: state.found, + routing_warnings: state.routingWarnings, + attemptBatch: collector.finalize('a2a', 'a2a_normalized', { truncated: state.truncated }), + }; +} + +interface A2AScanState { + readonly entries: readonly MentionPatternEntry[]; + readonly found: CatId[]; + readonly seen: Set; + readonly routingWarnings: CatRoutingError[]; + readonly collector: RoutingAttemptCollector; + /** 5. Safety limit hit — scan continues read-only (T-A 右截断 row). */ + capReached: boolean; + truncated: boolean; +} + +function scanA2ARouteLine(rawLine: string, lineOffset: number, state: A2AScanState): void { + const leadingWs = rawLine.match(/^\s*/)?.[0].length ?? 0; + const lowered = rawLine.slice(leadingWs).toLowerCase(); + const normalized = lowered.replace(LEADING_MARKDOWN_MENTION_PREFIX_RE, ''); + if (!normalized.startsWith('@')) return; + const tokenBase = lineOffset + leadingWs + (lowered.length - normalized.length); + + let cursor = 0; + while (cursor < normalized.length) { + const segment = normalized.slice(cursor); + const entry = matchA2AEntryAt(segment, state.entries); + + if (!entry) { + // T-A 改造②: tokenize the unmatched token (@ up to the next boundary) + // before abandoning the rest of the line (existing break preserved). + const length = a2aUnknownTokenLength(segment); + emitA2AAttempt(state, 'unknown_token', segment.slice(0, length), { + start: tokenBase + cursor, + end: tokenBase + cursor + length, + }); + return; } - let cursor = 0; - while (cursor < normalized.length && found.length < MAX_A2A_MENTION_TARGETS) { - const segment = normalized.slice(cursor); - let matched = false; + const outcome = evaluateA2AToken(entry, state); + emitA2AAttempt( + state, + outcome, + entry.pattern, + { start: tokenBase + cursor, end: tokenBase + cursor + entry.pattern.length }, + // F257 #1: an ambiguous token has multiple holders — no single target + // (validator contract: target present iff outcome is single-target). + outcome === 'ambiguous' ? undefined : entry.catId, + ); + if (state.truncated) return; - for (const entry of entries) { - if (!segment.startsWith(entry.pattern)) continue; - const charAfter = segment[entry.pattern.length]; - const isBoundary = !charAfter || TOKEN_BOUNDARY_RE.test(charAfter) || !HANDLE_CONTINUATION_RE.test(charAfter); - if (!isBoundary) continue; - // F182 KD-10: resolver check at match-time (not at pattern-build time) - const resolved = resolveCatTarget(entry.catId); - if ('error' in resolved) { - if (!seen.has(entry.catId)) { - seen.add(entry.catId); - routing_warnings.push(resolved.error); - } - } else if (!seen.has(entry.catId)) { - seen.add(entry.catId); - found.push(entry.catId); - } - cursor += entry.pattern.length; - matched = true; - break; // longest-match-first: lock one winner at current cursor - } + cursor += entry.pattern.length; + while (cursor < normalized.length && TOKEN_BOUNDARY_RE.test(normalized[cursor]!)) { + cursor += 1; + } + if (normalized[cursor] !== '@') return; + } +} - if (!matched) break; +function matchA2AEntryAt(segment: string, entries: readonly MentionPatternEntry[]): MentionPatternEntry | null { + for (const entry of entries) { + if (!segment.startsWith(entry.pattern)) continue; + const charAfter = segment[entry.pattern.length]; + const isBoundary = !charAfter || TOKEN_BOUNDARY_RE.test(charAfter) || !HANDLE_CONTINUATION_RE.test(charAfter); + if (!isBoundary) continue; + return entry; // longest-match-first: entries are sorted, first hit wins + } + return null; +} - while (cursor < normalized.length && TOKEN_BOUNDARY_RE.test(normalized[cursor]!)) { - cursor += 1; - } - if (normalized[cursor] !== '@') { - break; +/** Token length from `@` up to the next boundary char (T-A 改造② token extraction). */ +function a2aUnknownTokenLength(segment: string): number { + let end = 1; + while (end < segment.length && !TOKEN_BOUNDARY_RE.test(segment[end]!)) { + end += 1; + } + return end; +} + +/** + * Applies routing effects (found/seen/warnings — unchanged behavior) unless the + * cap was reached, and returns the T-A outcome for the token. Outcome names map + * 1:1 to T-A parserMode=a2a rows; priority order is the table's row order. + */ +function evaluateA2AToken(entry: MentionPatternEntry, state: A2AScanState): RoutingAttemptOutcome { + // F257 #1 (dev-628ea4d1): multi-holder pattern → refuse to route, surface the + // holders' unambiguous handles. Checked before self-exclusion — with several + // holders we do not even guess whether the author meant themselves. + if (entry.contenders && entry.contenders.length > 1) { + if (!state.capReached) { + const alreadyWarned = state.routingWarnings.some( + (w) => w.kind === 'mention_ambiguous' && w.mention === entry.pattern, + ); + if (!alreadyWarned) { + state.routingWarnings.push({ + kind: 'mention_ambiguous', + mention: entry.pattern, + candidates: buildAmbiguousCandidates(entry.contenders), + }); } } + return 'ambiguous'; + } + if (entry.isSelf) return 'self_excluded'; + // F182 KD-10: resolver check at match-time (not at pattern-build time) + const resolved = resolveCatTarget(entry.catId); + if ('error' in resolved) { + if (!state.capReached && !state.seen.has(entry.catId)) { + state.seen.add(entry.catId); + state.routingWarnings.push(resolved.error); + } + return 'disabled_cat'; } + if (state.seen.has(entry.catId)) return 'duplicate'; + if (!state.capReached) { + state.seen.add(entry.catId); + state.found.push(entry.catId); + } + return 'resolved'; +} - return { mentions: found, routing_warnings }; +/** + * Live mode: record the draft and flip to read-only once the resolve cap is hit. + * Read-only mode (T-A 右截断 row): no drafts, no routing effects — the first + * metric-affecting token confirms real truncation and invalidates the batch. + */ +function emitA2AAttempt( + state: A2AScanState, + outcome: RoutingAttemptOutcome, + token: string, + span: RoutingTokenSpan, + targetCatId?: CatId, +): void { + if (state.capReached) { + if (isMetricEligibleOutcome(outcome)) state.truncated = true; + return; + } + state.collector.add(span, token, outcome, targetCatId); + if (state.found.length >= MAX_A2A_MENTION_TARGETS) state.capReached = true; } /** diff --git a/packages/api/src/domains/cats/services/agents/routing/cat-signature-lint.ts b/packages/api/src/domains/cats/services/agents/routing/cat-signature-lint.ts new file mode 100644 index 0000000000..aa53327475 --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/routing/cat-signature-lint.ts @@ -0,0 +1,107 @@ +/** + * F257 修复清单 #4 — message-signature structural lint (O2→O1), detection layer. + * + * The trailing `[昵称/模型🐾]` signature is an L0 identity contract enforced only + * by prompt text (O2 观测层): governance-l0 「用自己的身份签名 `[昵称/模型🐾]`, + * 签名必须含模型型号」. dev-7a882ba0: Fable 漏签靠 operator 人工发现 — zero + * structural coverage. This module upgrades the convention to a regex-decidable + * structural assertion (O1 结构强制): does an agent message end with a + * contract-shaped `[nickname/model🐾]` signature? + * + * STRICTNESS (sol R1 P1-3). This is a COMPLIANCE lint, so it asserts the current + * contract shape — nickname + '/' + model + 🐾 — and does NOT reuse + * `isCatSignatureLine` from `cat-signature-strip.ts`. That matcher is a + * routing-STRIP matcher, permissive BY DESIGN (it also accepts pawed-slashless + * `[Spark🐾]` and legacy un-pawed `[砚砚/GPT-5.5]` so historical routing suffixes + * still get stripped). Reusing it as a compliance predicate produced false + * negatives — model-less / paw-less signatures counted as compliant. The two + * matchers are kept separate: strip = permissive (routing), lint = strict + * (compliance). Scope split: presence-only (contract SHAPE present) — matching + * the signature to the POSTING cat (identity-correctness) stays deferred. + * + * SCOPE — two-phase (F257 owner vision-guardian review 2026-07-20; AC 完成 ≠ + * feature 完成). This module + the persistence seams are the **detection layer** + * (O1 structural detection, message-level observable). The harness **ledger + * closure** — auto-emitting a deviation on miss, attributed to + * `obj-identity-integrity` (automating the manual `report_harness_signal` that + * recorded dev-7a882ba0) — is DEFERRED to after #3 (objective registry). Why + * deferred: the ledger reads DeviationEventLog / GuardRejectionEventLog / eval + * verdicts, NOT `message.extra`; a correct deviation needs a *registered* + * objective (else it reintroduces the free-string-objective archaeology #3 + * fixes) + the segment/condition attribution infra of the #2/#3 data root. + * `extra.signatureLint` is the interim detection observable — NOT the ledger + * closure; do not read extra-only as "#4 complete". + */ + +/** + * Strict compliance matcher for the current signature contract `[昵称/模型🐾]`: + * '[' + nickname + '/' + model + 🐾 + ']'. The FIRST slash delimits + * nickname/model; the model MAY be provider-qualified — i.e. contain further + * slashes (e.g. opencode's live `defaultModel: "codex-for-me/gpt-5.4"`), so the + * model class does NOT exclude '/'. Both captured components must be NON-BLANK + * after trim. Rejects `[Spark🐾]` (no model), `[砚砚/GPT-5.5]` (no paw), and + * `[ /model🐾]` / `[nick/ 🐾]` (blank component) — forms the permissive strip + * matcher tolerates or a naive single-slash regex would mis-handle (sol R4 P1). + */ +const STRICT_SIGNATURE_LINE_RE = /^\s*\[([^[\]\n/]+)\/([^[\]\n]+)🐾\]\s*$/u; + +function isContractSignatureLine(line: string): boolean { + const m = STRICT_SIGNATURE_LINE_RE.exec(line); + if (!m) return false; + // nickname (m[1], before first slash) and model (m[2], may be provider-qualified) + // must both be non-blank after trim — the regex classes admit whitespace-only. + return (m[1]?.trim().length ?? 0) > 0 && (m[2]?.trim().length ?? 0) > 0; +} + +export interface SignatureLintResult { + /** true iff the last non-blank line is a contract-shaped `[nickname/model🐾]` signature. */ + signed: boolean; + /** the matched signature line (trimmed) when signed; null otherwise. */ + signatureLine: string | null; +} + +const UNSIGNED: SignatureLintResult = { signed: false, signatureLine: null }; + +/** + * Structurally lint whether `text` ends with a trailing contract-shaped + * signature. + * + * Walks from the last line backwards skipping blank lines; the first non-blank + * line decides. A signature that is NOT trailing (content follows it) does not + * count — the L0 convention is that the signature is the final line. + */ +export function lintCatSignature(text: string): SignatureLintResult { + if (!text) return UNSIGNED; + const lines = text.split(/\r?\n/); + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i] ?? ''; + if (line.trim() === '') continue; // skip trailing blank lines + return isContractSignatureLine(line) ? { signed: true, signatureLine: line.trim() } : UNSIGNED; + } + return UNSIGNED; // all-blank / empty +} + +/** + * Post-seam projection: the observe-only `extra.signatureLint` fragment for a + * message. Empty for blank/whitespace content — pure-media posts carry no text + * signature, so they get no lint verdict and stay out of the sign-rate + * denominator. Spread into the message `extra` bag at every text-bearing + * cat-final persistence seam (callback post + serial/parallel stream final). + */ +export function signatureLintExtra(text: string): { signatureLint?: { signed: boolean } } { + if (!text.trim()) return {}; + return { signatureLint: { signed: lintCatSignature(text).signed } }; +} + +/** + * Forward an already-computed lint verdict when a stored message's `extra` is + * re-projected onto a broadcast / read-model payload (mirrors the web-side + * `pickSignatureLint`). Kept as a 1-line forwarder so the (already large) + * broadcast closures add no branch-complexity and the 4 broadcast sites stay + * consistent (sol R4 P2). + */ +export function pickSignatureLint(extra: { signatureLint?: { signed: boolean } } | null | undefined): { + signatureLint?: { signed: boolean }; +} { + return extra?.signatureLint ? { signatureLint: extra.signatureLint } : {}; +} diff --git a/packages/api/src/domains/cats/services/agents/routing/cat-target-resolver.ts b/packages/api/src/domains/cats/services/agents/routing/cat-target-resolver.ts index da87635ed3..378691d33b 100644 --- a/packages/api/src/domains/cats/services/agents/routing/cat-target-resolver.ts +++ b/packages/api/src/domains/cats/services/agents/routing/cat-target-resolver.ts @@ -21,19 +21,81 @@ function buildAlts(excludeId: string | null, preferFamily?: string): CatAlternat }); } +/** + * F257 #1 — routing token view: token → holders, merged from THREE intent + * sources (sol review F2/F5 rework): + * + * 1. mentionPatterns — explicit route aliases + * 2. `@` — a nickname is a routing identity: @砚砚 means "the cat + * nicknamed 砚砚", so EVERY nickname holder holds the token, regardless of + * who happens to list it as a pattern (dev-628ea4d1: pattern view alone + * routed @砚砚 to codex while five cats carried the nickname) + * 3. `@` — canonical reserved namespace: every cat always owns one + * explicit handle the parser recognizes, so ambiguity warnings can always + * offer a retryable disambiguation + * + * A token with >1 holder is ambiguous — routing refuses to guess. Shared by + * resolveCatTarget / AgentRouter / a2a-mentions so all three routing surfaces + * see the identical holder view. + */ +export function groupRoutingTokenHolders(): Map { + const holdersByToken = new Map(); + const add = (token: string, catId: CatId) => { + const key = token.trim().toLowerCase(); + if (!key || key === '@') return; + const holders = holdersByToken.get(key) ?? []; + if (!holders.includes(catId)) holders.push(catId); + holdersByToken.set(key, holders); + }; + for (const [id, cfg] of Object.entries(catRegistry.getAllConfigs())) { + const catId = id as CatId; + for (const p of cfg.mentionPatterns) add(p, catId); + if (cfg.nickname) add(`@${cfg.nickname}`, catId); + add(`@${id}`, catId); + } + return holdersByToken; +} + +/** + * F257 #1 (dev-628ea4d1): disambiguation candidates for a multi-holder token. + * Each candidate must carry a handle that the SAME parser resolves uniquely + * (sol F5: a suggested handle the parser doesn't recognize sends the retry to + * the default cat). Preference: an unambiguous explicit pattern → canonical + * @catId (always present in the token view; unique unless another cat squats + * on it, which the view itself then surfaces as ambiguous). + */ +export function buildAmbiguousCandidates(holderIds: readonly string[]): CatAlternative[] { + const roster = getRoster(); + const configs = catRegistry.getAllConfigs(); + const holdersByToken = groupRoutingTokenHolders(); + const isUnique = (token: string) => (holdersByToken.get(token.trim().toLowerCase()) ?? []).length === 1; + return holderIds.map((id) => { + const cfg = configs[id]; + const uniqueHandle = [...(cfg?.mentionPatterns ?? []), `@${id}`].find(isUnique); + return { + catId: id as CatId, + mention: uniqueHandle ?? `@${id}`, + displayName: cfg?.displayName ?? id, + family: roster[id]?.family ?? '', + }; + }); +} + export function resolveCatTarget(mentionOrId: string): { ok: CatId } | { error: CatRoutingError } { const input = (mentionOrId.startsWith('@') ? mentionOrId.slice(1) : mentionOrId).toLowerCase(); const configs = catRegistry.getAllConfigs(); let catId: string | undefined = catRegistry.has(input) ? input : undefined; if (!catId) { - outer: for (const [id, cfg] of Object.entries(configs)) { - for (const p of cfg.mentionPatterns) { - if ((p.startsWith('@') ? p.slice(1) : p).toLowerCase() === input) { - catId = id; - break outer; - } - } + // F257 #1: consult the unified token view (patterns ∪ nicknames ∪ canonical) + // — a token held by more than one cat refuses resolution rather than + // silently picking one (sol F2: nickname collisions must surface here too). + const holders = groupRoutingTokenHolders().get(`@${input}`) ?? []; + if (holders.length > 1) { + return { + error: { kind: 'mention_ambiguous', mention: mentionOrId, candidates: buildAmbiguousCandidates(holders) }, + }; } + catId = holders[0] as string | undefined; } if (!catId) return { error: { kind: 'cat_not_found', mention: mentionOrId, alternatives: buildAlts(null) } }; // KD-9: two-step check — isCatAvailable not used (it returns true for not-in-roster) diff --git a/packages/api/src/domains/cats/services/agents/routing/format-briefing.ts b/packages/api/src/domains/cats/services/agents/routing/format-briefing.ts index 9acd57877a..f0f34998b6 100644 --- a/packages/api/src/domains/cats/services/agents/routing/format-briefing.ts +++ b/packages/api/src/domains/cats/services/agents/routing/format-briefing.ts @@ -195,6 +195,7 @@ export function buildBriefingMessage( const rich: RichMessageExtra = { v: 1, blocks: [card] }; return { + provenance: { author: 'system', routed: false, observation: 'original' }, // sol R3 P1-1 threadId, userId: 'system', catId: null, diff --git a/packages/api/src/domains/cats/services/agents/routing/guards/routing-guard-remedial.ts b/packages/api/src/domains/cats/services/agents/routing/guards/routing-guard-remedial.ts index 44c1574434..9599848dca 100644 --- a/packages/api/src/domains/cats/services/agents/routing/guards/routing-guard-remedial.ts +++ b/packages/api/src/domains/cats/services/agents/routing/guards/routing-guard-remedial.ts @@ -12,6 +12,8 @@ * KD-8 safe:只看"有无机械出口信号",零意图分类器。 */ +import type { CompletionRequirement } from '../route-helpers.js'; + /** Routing-tool substrings that count as a legitimate exit (持球/群发传球). */ const ROUTING_TOOL_SUBSTRINGS = ['hold_ball', 'multi_mention'] as const; @@ -46,6 +48,29 @@ export function hasValidRoutingExit(input: RoutingExitInput): boolean { return false; } +/** F257 LI-001: any tool action or an existing mechanical routing exit satisfies the wake-up contract. */ +export function hasActionOrRoutingExit(input: RoutingExitInput): boolean { + return input.toolNames.length > 0 || hasValidRoutingExit(input); +} + +export interface ActionLivenessInput extends RoutingExitInput { + readonly completionRequirement?: CompletionRequirement; + /** Shared one-shot budget with the existing routing guard. */ + readonly attempted: boolean; + readonly hadError: boolean; + readonly aborted: boolean; +} + +export function shouldRemediateActionLiveness(input: ActionLivenessInput): boolean { + return ( + input.completionRequirement === 'action-or-routing-exit' && + !input.attempted && + !input.hadError && + !input.aborted && + !hasActionOrRoutingExit(input) + ); +} + export interface RemediateInput extends RoutingExitInput { /** service.needsServerRoutingGuard?.() — only codex-family is true (KD-13). */ readonly needsGuard: boolean; @@ -80,3 +105,15 @@ export const REMEDIAL_PROMPT = export function buildRemedialPrompt(): string { return REMEDIAL_PROMPT; } + +export const ACTION_LIVENESS_REMEDIAL_PROMPT = + '[动作活性守卫] 这次持球唤醒只产生了空响应或纯文本承诺,没有形成可验证动作或明确路由终态。\n' + + '现在只做一个具体收尾动作:\n' + + '- 条件已满足且能继续 → 立即调用完成下一步所需的工具;\n' + + '- 需要另一只猫或co-creator处理 → 用行首 @句柄 / @co-creator 明确传球;\n' + + '- 仍需等待无回调外部条件 → 调用 cat_cafe_hold_ball;需要并行分派 → 调用 cat_cafe_multi_mention。\n' + + '只回复文字、纯文本确认或承诺稍后处理都不算完成;不要复述刚才的内容。'; + +export function buildActionLivenessRemedialPrompt(): string { + return ACTION_LIVENESS_REMEDIAL_PROMPT; +} diff --git a/packages/api/src/domains/cats/services/agents/routing/route-helpers.ts b/packages/api/src/domains/cats/services/agents/routing/route-helpers.ts index fb58bbd285..eaaa391abd 100644 --- a/packages/api/src/domains/cats/services/agents/routing/route-helpers.ts +++ b/packages/api/src/domains/cats/services/agents/routing/route-helpers.ts @@ -74,6 +74,8 @@ export interface RouteStrategyDeps { ballCustody?: import('../../../../ball-custody/BallCustodyIngest.js').IBallCustodyIngest; /** F237 Phase 2 (AC-P2-8): Injection trace store for pipeline observability. optional, fail-open */ injectionTraceStore?: import('../../../../prompt-hooks/InjectionTraceStore.js').InjectionTraceStore; + /** F257 Phase A (Line B): Guard rejection event log — fail-open observation layer */ + guardRejectionLog?: import('../../../../../infrastructure/harness-eval/GuardRejectionEventLog.js').GuardRejectionEventLog; } /** Mutable context for tracking persistence failures across the generator boundary. @@ -87,6 +89,9 @@ export interface PersistenceContext { richBlocks?: import('@cat-cafe/shared').RichBlock[]; } +/** Invocation-level completion contract for wake-ups that must produce concrete progress. */ +export type CompletionRequirement = 'action-or-routing-exit'; + /** Common options for both strategies */ export interface RouteOptions { contentBlocks?: readonly MessageContent[] | undefined; @@ -169,6 +174,8 @@ export interface RouteOptions { * Separate from frustrationAutoIssueEligible because A2A/multi-mention callbacks * suppress frustration issues but still need verdict-pass handoff guards. */ verdictPassWarningEnabled?: boolean | undefined; + /** F257 LI-001: require a real tool action or an explicit routing exit before successful completion. */ + completionRequirement?: CompletionRequirement | undefined; /** F254 B3: Freshness re-invoke enqueue — called when doneMsg.metadata.freshnessReinvoke.shouldReinvoke * is true. Enqueues a new invocation for the same (cat, thread) to address unseen messages. */ freshnessReinvokeEnqueue?: diff --git a/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts b/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts index 170dd9dd44..51329a1e16 100644 --- a/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts +++ b/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts @@ -29,8 +29,15 @@ import { prepareGuideContext, } from '../../../../guides/GuideRoutingInterceptor.js'; import { triggerRecallCorrelation } from '../../../../memory/recall-correlation-hook.js'; -import { drainCapturedTraces } from '../../../../prompt-hooks/PipelinePromptBuilder.js'; +import { persistNativeL0SessionTrace } from '../../../../prompt-hooks/native-l0-trace.js'; +import { drainCapturedTraces, refreshOverrideSnapshot } from '../../../../prompt-hooks/PipelinePromptBuilder.js'; import { getTraceStore } from '../../../../prompt-hooks/trace-bootstrap.js'; +// F257: Pipeline trace bridge — richer per-hook traces, replaces redundant v0 re-collection +import { + buildFromPipeline, + buildReplaySnapshots, + captureSurroundingMessageIds, +} from '../../../../prompt-hooks/trace-bridge.js'; // F237: Injection trace (v0 — fire-and-forget observability) import { buildTraceDetail, buildTraceSummary, collectTrace } from '../../../../prompt-hooks/trace-collector.js'; import { assembleContext } from '../../context/ContextAssembler.js'; @@ -57,6 +64,8 @@ import { mergeStreams } from '../invocation/stream-merge.js'; import { resolveDefaultClaudeMcpServerPath } from '../providers/ClaudeAgentService.js'; import { parseA2AMentions } from '../routing/a2a-mentions.js'; import { accumulateTextAggregate } from '../text-aggregation.js'; +import { analyzeA2AMentions } from './a2a-mentions.js'; +import { signatureLintExtra } from './cat-signature-lint.js'; import { type ContextEvalInput, extractContextEvalSignals } from './context-eval.js'; import { buildBriefingMessage } from './format-briefing.js'; import { extractRichFromText, isValidRichBlock } from './rich-block-extract.js'; @@ -242,12 +251,16 @@ export async function* routeParallel( const hasNativeL0 = service.injectsL0Natively?.() ?? false; // Staging is injected in invoke-single-cat independently of staticIdentity // (Cloud R2 P1 #2237 L1099). See route-serial.ts for the architecture rationale. + // F237 PR3: refresh override snapshot before synchronous pipeline execution. + // Mirrors route-serial.ts — no-ops if no override store configured. + await refreshOverrideSnapshot(); const staticIdentity = hasNativeL0 ? buildStaticIdentityPackOnly(catId, { packBlocks }) : buildStaticIdentity(catId, { mcpAvailable, packBlocks }); // F237: drain session trace IMMEDIATELY — before any await that could let // another parallel cat overwrite the module-global capture buffer. - drainCapturedTraces(); + // F257: store the result for pipeline bridge persistence (was discarded pre-F257). + const pipelineSessionTrace = drainCapturedTraces(); // F041: inject HTTP callback only when MCP is NOT actually available (fallback) const mcpInstructions = needsMcpInjection(mcpAvailable, catConfig?.clientId) ? buildMcpCallbackInstructions({ @@ -319,11 +332,12 @@ export async function* routeParallel( ...conciergeContextForCat(conciergeCtx, catId as string), }); // F237: drain turn trace IMMEDIATELY — same race-safety as session drain above. - drainCapturedTraces(); + // F257: store the result for pipeline bridge persistence (was discarded pre-F257). + const pipelineTurnTrace = drainCapturedTraces(); - // F237 Phase 2: Pipeline trace capture drained above (lines 250, 322) to prevent - // stale module-global buffer in concurrent Promise.all execution. Persistence is - // handled by the v0 trace path below (after all route-level content is assembled). + // F237 Phase 2: Pipeline trace capture drained above to prevent stale module-global + // buffer in concurrent Promise.all execution. F257: traces now stored in locals for + // bridge persistence below (per-hook segments instead of per-turn aggregates). const continuityCapsule = buildCapsuleFromRouteState({ threadId, @@ -380,36 +394,124 @@ export async function* routeParallel( } } - // F237: fire-and-forget injection trace persist (v0 — observability only) - // Placed after bootstrapCtx so per-turn trace covers ALL route-level - // injected system/control content (invocation + mode prompt + bootstrap + MCP). + // F237/F257: fire-and-forget injection trace persist. + // F257 bridge: prefer pipeline traces (per-hook, no redundant buildStaticIdentity call). + // Falls back to v0 collectTrace when pipeline traces are unavailable. // Skip if cat is already cancelled (avoid phantom trace for turns that never happen). + // F257 Console 判据④:parallel route anchors turnId to the user message + cat. + const messageAnchorId = currentUserMessageId ?? null; + const traceTurnId = crypto.randomUUID(); const preTraceSignal = signalForCat?.(catId) ?? signal; try { const traceStore = getTraceStore(); if (traceStore && !preTraceSignal?.aborted) { - const traceTurnId = crypto.randomUUID(); - const traceModePrompt = modeSystemPromptByCat?.[catId as string] ?? modeSystemPrompt ?? ''; - const traceTurnContent = [invocationContext, traceModePrompt, bootstrapCtx, mcpInstructions] - .filter(Boolean) - .join('\n\n---\n\n'); - const collected = collectTrace(catId as string, staticIdentity, traceTurnContent, hasNativeL0, { - mcpAvailable, - packBlocks, - }); - const traceMeta = { turnId: traceTurnId, threadId, catId: catId as string }; - const summary = buildTraceSummary(collected, traceMeta); - const detail = buildTraceDetail(collected, traceMeta); - traceStore.persist(summary, detail).catch((err) => { - log.warn({ err, threadId, catId }, '[F237] injection trace persist failed (fire-and-forget)'); - }); + if (hasNativeL0) { + // F257 #2: native-L0 identity (L1-L7) is delivered by the native L0 compiler, + // not the session pipeline. Source the trace from that ACTUAL compiled artifact + // (cache-first, shares the provider's compile) — fire-and-forget so it never + // taxes the model critical path; visible warning if the manifest is empty. + void persistNativeL0SessionTrace({ + traceStore, + catId: catId as string, + threadId, + turnId: traceTurnId, + turnResult: pipelineTurnTrace.turn, + log, + ownerUserId: userId, + messageAnchorId, + messageStore: deps.messageStore, + }); + } else { + // Non-native: session identity IS pipeline-delivered (S-series captured trace). + const bridgeResult = buildFromPipeline(pipelineSessionTrace.session, pipelineTurnTrace.turn, { + turnId: traceTurnId, + threadId, + catId: catId as string, + hasNativeL0, + }); + if (bridgeResult) { + traceStore.persist(bridgeResult.summary, bridgeResult.detail).catch((err) => { + log.warn({ err, threadId, catId }, '[F257] pipeline trace persist failed (fire-and-forget)'); + }); + // Capture context and persist replay snapshots off the model critical path. + void (async () => { + const surroundingCapture = await captureSurroundingMessageIds( + deps.messageStore, + threadId, + messageAnchorId, + userId, + ); + const snapshots = buildReplaySnapshots(pipelineSessionTrace.session, pipelineTurnTrace.turn, { + threadId, + turnId: traceTurnId, + catId: catId as string, + timestamp: bridgeResult.detail.timestamp, + ownerUserId: userId, + messageAnchorId, + surroundingMessageIds: surroundingCapture.ids, + surroundingMessagesGap: surroundingCapture.gap, + }); + await traceStore.persistReplaySnapshots(threadId, traceTurnId, snapshots); + })().catch((err) => { + log.warn({ err, threadId, catId }, '[F257] replay snapshot persist failed (fire-and-forget)'); + }); + } else { + // v0 fallback: re-collect traces via annotateSegments (legacy/unknown-cat path) + const traceModePrompt = modeSystemPromptByCat?.[catId as string] ?? modeSystemPrompt ?? ''; + const traceTurnContent = [invocationContext, traceModePrompt, bootstrapCtx, mcpInstructions] + .filter(Boolean) + .join('\n\n---\n\n'); + const collected = collectTrace(catId as string, staticIdentity, traceTurnContent, hasNativeL0, { + mcpAvailable, + packBlocks, + }); + const traceMeta = { turnId: traceTurnId, threadId, catId: catId as string }; + const summary = buildTraceSummary(collected, traceMeta); + const detail = buildTraceDetail(collected, traceMeta); + traceStore.persist(summary, detail).catch((err) => { + log.warn({ err, threadId, catId }, '[F237] injection trace persist failed (fire-and-forget)'); + }); + // Capture context off the model critical path. + void (async () => { + const surroundingCapture = await captureSurroundingMessageIds( + deps.messageStore, + threadId, + messageAnchorId, + userId, + ); + const v0Snapshots = collected.segments + .filter((s) => s.status === 'observed') + .map((s): import('@cat-cafe/shared').ReplaySnapshot => ({ + segmentId: s.segmentId, + threadId, + turnId: traceTurnId, + timestamp: summary.timestamp, + catId: catId as string, + stage: s.stage, + pipelineStatus: s.pipelineStatus ?? 'observed', + version: s.version ?? null, + content: s.content ?? null, + contentSourceKind: s.contentSourceKind ?? 'aggregate', + contentSourceRef: s.segmentId, + templateVars: s.templateVars ?? null, + messageAnchorId, + surroundingMessageIds: surroundingCapture.ids, + surroundingMessagesGap: surroundingCapture.gap, + ownerUserId: userId, + })); + await traceStore.persistReplaySnapshots(threadId, traceTurnId, v0Snapshots); + })().catch((err) => { + log.warn({ err, threadId, catId }, '[F257] v0 replay snapshot persist failed (fire-and-forget)'); + }); + } + } } // v0 collectTrace → buildStaticIdentity(annotateSegments: true) re-populates // the module-global capturedSessionTrace without draining. Clear it so the next // invocation (especially native-L0 pack-only) doesn't persist stale session traces. if (deps.injectionTraceStore) drainCapturedTraces(); } catch { - /* F237: trace collection must never break invocation */ + /* F237/F257: trace collection must never break invocation */ } let prompt: string; @@ -1130,6 +1232,7 @@ export async function* routeParallel( // Gap 3: persist separate connector message for ConnectorBubble rendering try { const stored = await deps.messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, // sol R3 P1-1 userId, catId: null, content: `投票结果: ${voteState.question}`, @@ -1230,6 +1333,9 @@ export async function* routeParallel( origin: 'stream', timestamp: invocationStartedAt, threadId, + // F257 V1 authority embed (T-A §3.4 / §4.5.1; sol R1 P1-1 cohort audit) + routingFact: analyzeA2AMentions(storedContent, msg.catId as CatId).attemptBatch, + provenance: { author: 'cat', routed: true, observation: 'original' }, // sol R3 P1-1 ...(thinking && thinking.length > 0 ? { thinking: renderThinkingChunks(thinking) } : {}), ...(meta ? { metadata: meta } : {}), ...(catTools && catTools.length > 0 ? { toolEvents: catTools } : {}), @@ -1246,6 +1352,9 @@ export async function* routeParallel( } : {}), ...(msg.tracing ? { tracing: msg.tracing } : {}), + // F257 #4 (sol R1 P1-1): stamp signature lint on the ordinary agent + // stream-final so it enters the sign-rate denominator, not just callback posts. + ...signatureLintExtra(storedContent), }, }); const triagePlanStore = deps.invocationDeps.conciergeTriagePlanStore; @@ -1330,6 +1439,8 @@ export async function* routeParallel( if (shouldPersistNoTextMessage) { try { await deps.messageStore.append({ + routingFact: analyzeA2AMentions('', msg.catId as CatId).attemptBatch, // F257 zero-token marker (T-A) + provenance: { author: 'cat', routed: true, observation: 'original' }, // sol R3 P1-1 userId, catId: msg.catId as CatId, content: '', @@ -1427,6 +1538,9 @@ export async function* routeParallel( origin: 'stream', timestamp: invocationStartedAt, threadId, + // F257 V1 (sol R1 P1-1): empty content still ran the parser — zero-token marker + routingFact: analyzeA2AMentions('', msg.catId as CatId).attemptBatch, + provenance: { author: 'cat', routed: true, observation: 'original' }, // sol R3 P1-1 ...(thinking && thinking.length > 0 ? { thinking: renderThinkingChunks(thinking) } : {}), ...(meta ? { metadata: meta } : {}), toolEvents: catTools, @@ -1486,6 +1600,7 @@ export async function* routeParallel( const cliDiag = catCliDiagnostics.get(msg.catId); try { await deps.messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, // sol R3 P1-1 userId: 'system', catId: null, content: `Error: ${errorText}`, diff --git a/packages/api/src/domains/cats/services/agents/routing/route-serial.ts b/packages/api/src/domains/cats/services/agents/routing/route-serial.ts index 804b99c9e4..87fb66676a 100644 --- a/packages/api/src/domains/cats/services/agents/routing/route-serial.ts +++ b/packages/api/src/domains/cats/services/agents/routing/route-serial.ts @@ -24,6 +24,7 @@ import { context, trace } from '@opentelemetry/api'; import { getCatContextBudget } from '../../../../../config/cat-budgets.js'; import { getConfigSessionStrategy, isSessionChainEnabled } from '../../../../../config/cat-config-loader.js'; import { getCatVoice } from '../../../../../config/cat-voices.js'; +import { ledgerIdForGuard } from '../../../../../infrastructure/harness-eval/guard-ledger-registry.js'; import { createModuleLogger } from '../../../../../infrastructure/logger.js'; import { AGENT_ID, @@ -36,6 +37,8 @@ import { } from '../../../../../infrastructure/telemetry/genai-semconv.js'; import { a2aDispatchCount, + c2AckLivenessChecked, + c2AckLivenessHintEmitted, c2ExitChecked, c2VerdictHintEmitted, c2VerdictWithoutPassCount, @@ -59,6 +62,7 @@ import { buildHandedEvent, buildInvocationHeartbeatEvent, buildInvocationStartedEvent, + buildVoidAckEvent, buildVoidPassEvent, } from '../../../../ball-custody/ball-custody-events.js'; import { conciergeContextForCat, prepareConciergeContext } from '../../../../concierge/ConciergeRoutingInterceptor.js'; @@ -75,8 +79,15 @@ import { prepareGuideContext, } from '../../../../guides/GuideRoutingInterceptor.js'; import { triggerRecallCorrelation } from '../../../../memory/recall-correlation-hook.js'; -import { drainCapturedTraces } from '../../../../prompt-hooks/PipelinePromptBuilder.js'; +import { persistNativeL0SessionTrace } from '../../../../prompt-hooks/native-l0-trace.js'; +import { drainCapturedTraces, refreshOverrideSnapshot } from '../../../../prompt-hooks/PipelinePromptBuilder.js'; import { getTraceStore } from '../../../../prompt-hooks/trace-bootstrap.js'; +// F257: Pipeline trace bridge — richer per-hook traces, replaces redundant v0 re-collection +import { + buildFromPipeline, + buildReplaySnapshots, + captureSurroundingMessageIds, +} from '../../../../prompt-hooks/trace-bridge.js'; // F237: Injection trace (v0 — fire-and-forget observability) import { buildTraceDetail, buildTraceSummary, collectTrace } from '../../../../prompt-hooks/trace-collector.js'; import { assembleContext } from '../../context/ContextAssembler.js'; @@ -107,7 +118,12 @@ import { invokeSingleCat } from '../invocation/invoke-single-cat.js'; import { buildMcpCallbackInstructions, needsMcpInjection } from '../invocation/McpPromptInjector.js'; import { getRichBlockBuffer } from '../invocation/RichBlockBuffer.js'; import { resolveDefaultClaudeMcpServerPath } from '../providers/ClaudeAgentService.js'; -import { detectInlineActionMentionsWithShadow, getMaxA2ADepth, parseA2AMentions } from '../routing/a2a-mentions.js'; +import { + analyzeA2AMentions, + detectInlineActionMentionsWithShadow, + getMaxA2ADepth, + parseA2AMentions, +} from '../routing/a2a-mentions.js'; import { isSubstantiveTool, peekStreakOnPush, @@ -116,11 +132,20 @@ import { updateStreakOnPush, } from '../routing/WorklistRegistry.js'; import { accumulateTextAggregate } from '../text-aggregation.js'; +import { classifyDurableTriggerResult, evaluateAckLiveness } from './a2a-ack-liveness.js'; import { formatA2AHandoffContent } from './a2a-handoff-label.js'; +import { signatureLintExtra } from './cat-signature-lint.js'; import { extractContextEvalSignals } from './context-eval.js'; import { validateRoutingSyntax } from './final-routing-slot.js'; import { buildBriefingMessage } from './format-briefing.js'; -import { buildRemedialPrompt, hasValidRoutingExit, shouldRemediateRouting } from './guards/routing-guard-remedial.js'; +import { + buildActionLivenessRemedialPrompt, + buildRemedialPrompt, + hasActionOrRoutingExit, + hasValidRoutingExit, + shouldRemediateActionLiveness, + shouldRemediateRouting, +} from './guards/routing-guard-remedial.js'; import { extractRichFromText, isValidRichBlock } from './rich-block-extract.js'; import type { RouteOptions, RouteStrategyDeps } from './route-helpers.js'; import { @@ -136,6 +161,7 @@ import { toStoredToolEvent, upsertMaxBoundary, } from './route-helpers.js'; +import type { RoutingAttemptBatch } from './routing-attempt.js'; import { resolveRoutingDecisions } from './routing-decision.js'; import { appendThinkingChunk, renderThinkingChunks } from './thinking-chunks.js'; import { detectMatchedVerdictKeyword, shouldWarnVerdictWithoutPass } from './verdict-detect.js'; @@ -179,6 +205,22 @@ function emitBallVoidPass( .catch((err) => log.warn({ threadId, err }, 'ball.void_pass ingest failed')); } +/** + * LI-005: fire-and-forget 旁路写 ball.void_ack(A2A 接球但无持久触发器 / 无路由出口 → 球静默死亡)。 + * 紧贴 ack-liveness-hint sample emit 调用(此时 storedMsgId 已绑定)。 + */ +function emitBallVoidAck( + ballCustody: IBallCustodyIngest | undefined, + threadId: string, + messageId: string | undefined, + a2aTriggerMessageId: string | undefined, +): void { + if (!ballCustody || !messageId) return; + ballCustody + .record(buildVoidAckEvent({ threadId, messageId, a2aTriggerMessageId, at: Date.now() })) + .catch((err) => log.warn({ threadId, err }, 'ball.void_ack ingest failed')); +} + function emitBallHandedCvo( ballCustody: IBallCustodyIngest | undefined, threadId: string, @@ -480,6 +522,7 @@ export async function* routeSerial( hasQueuedOrActiveAgentForCat, deferA2AEnqueue, freshnessReinvokeEnqueue, + completionRequirement, } = options; const previousResponses: { catId: CatId; content: string }[] = []; const thinkingMode = options.thinkingMode ?? 'play'; @@ -594,8 +637,8 @@ export async function* routeSerial( try { while (index < worklist.length) { const catId = worklist[index]!; - let routingGuardAttempted = false; - let routingGuardRemediated = false; + let guardRemedialAttempted = false; + let guardRemediated = false; // F-parallel-cancel: per-cat signal — canceling one cat skips ONLY that cat, not the // whole worklist. force-reset/cancelAll aborts every cat's controller, so all entries // skip = equivalent to stopping. Using the shared primaryController.signal made @@ -689,13 +732,21 @@ export async function* routeSerial( } const service = getService(deps.services, catId); const needsServerRoutingGuard = service.needsServerRoutingGuard?.() ?? false; + // LI-001 belongs to the cat explicitly woken by hold_ball. Downstream A2A cats + // are new handoff recipients and remain governed by their own routing contract. + const needsActionLivenessGuard = isOriginalTarget && completionRequirement === 'action-or-routing-exit'; + const needsBufferedGuard = needsServerRoutingGuard || needsActionLivenessGuard; const hasNativeL0 = service.injectsL0Natively?.() ?? false; + // F237 PR3: refresh override snapshot before synchronous pipeline execution. + // No-ops if no override store is configured (Redis unavailable). + await refreshOverrideSnapshot(); const staticIdentity = hasNativeL0 ? buildStaticIdentityPackOnly(catId, { packBlocks }) : buildStaticIdentity(catId, { mcpAvailable, packBlocks }); // F237: drain session trace synchronously — before any await between // buildStaticIdentity and buildInvocationContext (race-safety for parallel reuse). - drainCapturedTraces(); + // F257: capture pipeline trace for bridge persistence (was discarded pre-F257). + const pipelineSessionTrace = drainCapturedTraces(); // L0-budget-defense PR-B-impl (ADR-038 件套 ④): staging is NOT prepended // to staticIdentity here. Cloud R2 P1 #2237 L1099: folding staging into // staticIdentity breaks ADR-038 "每轮注入生效" contract on resumed @@ -801,7 +852,8 @@ export async function* routeSerial( ...conciergeContextForCat(conciergeCtx, catId as string), }); // F237: drain turn trace synchronously — no yield between build and drain. - drainCapturedTraces(); + // F257: capture pipeline trace for bridge persistence (was discarded pre-F257). + const pipelineTurnTrace = drainCapturedTraces(); const continuityCapsule = buildCapsuleFromRouteState({ threadId, catId: catId as string, @@ -865,34 +917,124 @@ export async function* routeSerial( } } - // F237: fire-and-forget injection trace persist (v0 — observability only) - // Placed after bootstrapContext so per-turn trace covers ALL route-level - // injected system/control content (invocation + mode prompt + bootstrap + MCP). + // F237/F257: fire-and-forget injection trace persist. + // F257 bridge: prefer pipeline traces (per-hook, no redundant buildStaticIdentity call). + // Falls back to v0 collectTrace when pipeline traces are unavailable. + // F257 Console 判据④:turnId is a unique trace UUID so repeated turns for the same + // anchor+cat do not overwrite each other; messageAnchorId is stored separately. + const messageAnchorId = streamReplyTo ?? currentUserMessageId ?? null; + const traceTurnId = crypto.randomUUID(); try { const traceStore = getTraceStore(); if (traceStore) { - const traceTurnId = crypto.randomUUID(); - const traceModePrompt = modeSystemPromptByCat?.[catId as string] ?? modeSystemPrompt ?? ''; - const traceTurnContent = [invocationContext, traceModePrompt, bootstrapContext, mcpInstructions] - .filter(Boolean) - .join('\n\n---\n\n'); - const trace = collectTrace(catId as string, staticIdentity, traceTurnContent, hasNativeL0, { - mcpAvailable, - packBlocks, - }); - const traceMeta = { turnId: traceTurnId, threadId, catId: catId as string }; - const summary = buildTraceSummary(trace, traceMeta); - const detail = buildTraceDetail(trace, traceMeta); - traceStore.persist(summary, detail).catch((err) => { - log.warn({ err, threadId, catId }, '[F237] injection trace persist failed (fire-and-forget)'); - }); + if (hasNativeL0) { + // F257 #2: native-L0 identity (L1-L7) is delivered by the native L0 compiler, + // not the session pipeline. Source the trace from that ACTUAL compiled artifact + // (cache-first, shares the provider's compile) — fire-and-forget so it never + // taxes the model critical path; visible warning if the manifest is empty. + void persistNativeL0SessionTrace({ + traceStore, + catId: catId as string, + threadId, + turnId: traceTurnId, + turnResult: pipelineTurnTrace.turn, + log, + ownerUserId: userId, + messageAnchorId, + messageStore: deps.messageStore, + }); + } else { + // Non-native: session identity IS pipeline-delivered (S-series captured trace). + const bridgeResult = buildFromPipeline(pipelineSessionTrace.session, pipelineTurnTrace.turn, { + turnId: traceTurnId, + threadId, + catId: catId as string, + hasNativeL0, + }); + if (bridgeResult) { + traceStore.persist(bridgeResult.summary, bridgeResult.detail).catch((err) => { + log.warn({ err, threadId, catId }, '[F257] pipeline trace persist failed (fire-and-forget)'); + }); + // Capture context and persist replay snapshots off the model critical path. + void (async () => { + const surroundingCapture = await captureSurroundingMessageIds( + deps.messageStore, + threadId, + messageAnchorId, + userId, + ); + const snapshots = buildReplaySnapshots(pipelineSessionTrace.session, pipelineTurnTrace.turn, { + threadId, + turnId: traceTurnId, + catId: catId as string, + timestamp: bridgeResult.detail.timestamp, + ownerUserId: userId, + messageAnchorId, + surroundingMessageIds: surroundingCapture.ids, + surroundingMessagesGap: surroundingCapture.gap, + }); + await traceStore.persistReplaySnapshots(threadId, traceTurnId, snapshots); + })().catch((err) => { + log.warn({ err, threadId, catId }, '[F257] replay snapshot persist failed (fire-and-forget)'); + }); + } else { + // v0 fallback: re-collect traces via annotateSegments (legacy/unknown-cat path) + const traceModePrompt = modeSystemPromptByCat?.[catId as string] ?? modeSystemPrompt ?? ''; + const traceTurnContent = [invocationContext, traceModePrompt, bootstrapContext, mcpInstructions] + .filter(Boolean) + .join('\n\n---\n\n'); + const traceV0 = collectTrace(catId as string, staticIdentity, traceTurnContent, hasNativeL0, { + mcpAvailable, + packBlocks, + }); + const traceMeta = { turnId: traceTurnId, threadId, catId: catId as string }; + const summary = buildTraceSummary(traceV0, traceMeta); + const detail = buildTraceDetail(traceV0, traceMeta); + traceStore.persist(summary, detail).catch((err) => { + log.warn({ err, threadId, catId }, '[F237] injection trace persist failed (fire-and-forget)'); + }); + // v0 collector emits aggregate segments; map them into snapshots manually. + // Capture context off the model critical path. + void (async () => { + const surroundingCapture = await captureSurroundingMessageIds( + deps.messageStore, + threadId, + messageAnchorId, + userId, + ); + const v0Snapshots = traceV0.segments + .filter((s) => s.status === 'observed') + .map((s): import('@cat-cafe/shared').ReplaySnapshot => ({ + segmentId: s.segmentId, + threadId, + turnId: traceTurnId, + timestamp: summary.timestamp, + catId: catId as string, + stage: s.stage, + pipelineStatus: s.pipelineStatus ?? 'observed', + version: s.version ?? null, + content: s.content ?? null, + contentSourceKind: s.contentSourceKind ?? 'aggregate', + contentSourceRef: s.segmentId, + templateVars: s.templateVars ?? null, + messageAnchorId, + surroundingMessageIds: surroundingCapture.ids, + surroundingMessagesGap: surroundingCapture.gap, + ownerUserId: userId, + })); + await traceStore.persistReplaySnapshots(threadId, traceTurnId, v0Snapshots); + })().catch((err) => { + log.warn({ err, threadId, catId }, '[F257] v0 replay snapshot persist failed (fire-and-forget)'); + }); + } + } } // v0 collectTrace → buildStaticIdentity(annotateSegments: true) re-populates // the module-global capturedSessionTrace without draining. Clear it so the next // invocation (especially native-L0 pack-only) doesn't persist stale session traces. if (deps.injectionTraceStore) drainCapturedTraces(); } catch { - /* F237: trace collection must never break invocation */ + /* F237/F257: trace collection must never break invocation */ } let deliveryBoundaryId: string | undefined; @@ -1050,9 +1192,27 @@ export async function* routeSerial( // cliDiagnostics alongside the error text so cold hydration (F5 reload) can // restore the folded panel — without this, only the legacy red-pill survives. let collectedCliDiagnostics: import('@cat-cafe/shared').CliDiagnostics | undefined; + const recordErrorMessage = (message: AgentMessage) => { + if (message.type !== 'error') return; + hadError = true; + // #267: errors before abort are real provider failures; errors after abort are cleanup + if (!catSignal?.aborted) hadProviderError = true; + if (message.error) { + collectedErrorText += `${collectedErrorText ? '\n' : ''}${message.error}`; + } + const meta = message.metadata as { cliDiagnostics?: import('@cat-cafe/shared').CliDiagnostics } | undefined; + if (meta?.cliDiagnostics && !collectedCliDiagnostics) { + collectedCliDiagnostics = meta.cliDiagnostics; + } + }; const collectedToolEvents: StoredToolEvent[] = []; // F148 OQ-2: Collect tool names for context eval signals const collectedToolNames: string[] = []; + // LI-005: Track confirmed-successful durable trigger tool names. + // All providers now emit tool_result events (Claude CLI bridge added in + // claude-ndjson-parser.ts R4 fix). Success classification uses + // classifyDurableTriggerResult (two-level: structural status → body parsing). + const confirmedCallbackToolNames: string[] = []; // #573: Track confirmed cat_cafe_post_message callback persistence let callbackPostConfirmed = false; let callbackPostMessageId: string | undefined; @@ -1065,6 +1225,11 @@ export async function* routeSerial( let confirmedLocalCallbackRoutingHasCoCreatorLineStartMention = false; const emittedBallHandedCvoMessageIds = new Set(); const structuredTargetCats = new Set(); + // LI-005 P2-2: confirmed structured targets — only populated on successful + // tool_result for post_message/cross_post_message. Unconfirmed tool_use inputs + // must not suppress ack-liveness hint (Codex R1 P2-2 fix). + const confirmedStructuredTargetCats = new Set(); + const pendingStructuredTargetsByTool = new Map(); // F060: Collect rich blocks emitted inline via system_info (not MCP buffer) const streamRichBlocks: import('@cat-cafe/shared').RichBlock[] = []; // F22 R2 P1-1: Capture own invocationId from stream (not getLatestId) @@ -1316,7 +1481,7 @@ export async function* routeSerial( // F177-H guard-enabled turns defer first-pass voice text because it may // be replaced by a remedial turn and must not be spoken early. if (voiceMode) { - if (needsServerRoutingGuard) { + if (needsBufferedGuard) { deferredVoiceInvocationId = ownInvocationId!; } else { voiceChunker = createVoiceChunker(ownInvocationId!); @@ -1344,7 +1509,7 @@ export async function* routeSerial( effectiveMsg.content, (effectiveMsg as { textMode?: 'append' | 'replace' }).textMode, ); - if (voiceMode && needsServerRoutingGuard) { + if (voiceMode && needsBufferedGuard) { deferredVoiceTextChunks.push(effectiveMsg.content); } else { voiceChunker?.feed(effectiveMsg.content); @@ -1401,9 +1566,15 @@ export async function* routeSerial( } if (effectiveMsg.type === 'tool_use') { - for (const target of collectStructuredTargetCatsFromInput(effectiveMsg.toolInput)) { + const targets = collectStructuredTargetCatsFromInput(effectiveMsg.toolInput); + for (const target of targets) { structuredTargetCats.add(target); } + // LI-005 P2-2: track pending targets by tool identity for confirmation on tool_result + if (targets.length > 0) { + const pendingKey = effectiveMsg.toolUseId ?? effectiveMsg.toolName ?? ''; + pendingStructuredTargetsByTool.set(pendingKey, targets); + } } // F148 OQ-2: Collect tool names for context eval @@ -1449,6 +1620,31 @@ export async function* routeSerial( callbackResult.messageId, callbackResult.threadId, ); + // LI-005: durable trigger success classification (Sol R3 P1 fix). + // Uses two-level check: structural toolResultStatus → tool-specific body parsing. + // Covers all 5 response shapes (hold_ball/register_scheduled_task/PR/issue/await_external). + if ( + classifyDurableTriggerResult( + completedToolName.toolName, + effectiveMsg.content, + (effectiveMsg as { toolResultStatus?: 'ok' | 'error' | 'unknown' }).toolResultStatus, + ) + ) { + confirmedCallbackToolNames.push(completedToolName.toolName); + } else if (callbackResult.confirmed) { + // Non-durable-trigger tools (post_message etc.): use existing parseCallbackPostResult + confirmedCallbackToolNames.push(completedToolName.toolName); + } + // LI-005 P2-2: confirm pending structured targets on successful tool_result. + // Only confirmed targets suppress the ack-liveness hint. + const pendingTargetKey = completedToolName.toolUseId ?? completedToolName.toolName; + const pendingTargets = pendingStructuredTargetsByTool.get(pendingTargetKey); + if (pendingTargets) { + if (callbackResult.confirmed) { + for (const t of pendingTargets) confirmedStructuredTargetCats.add(t); + } + pendingStructuredTargetsByTool.delete(pendingTargetKey); + } } // F188 Phase F AC-F10 (砚砚 六审 P1-B: also scope by catId for serial route consistency). // 砚砚 cloud-3 P1: also pass toolUseId for exact match when available; @@ -1593,22 +1789,7 @@ export async function* routeSerial( } } - if (effectiveMsg.type === 'error') { - hadError = true; - // #267: errors before abort are real provider failures; errors after abort are cleanup - if (!catSignal?.aborted) hadProviderError = true; - if (effectiveMsg.error) { - collectedErrorText += `${collectedErrorText ? '\n' : ''}${effectiveMsg.error}`; - } - // F212 Phase B (云端 codex P2-8): capture structured cliDiagnostics from - // metadata; keep the first one seen (canonical for this invocation). - const meta = effectiveMsg.metadata as - | { cliDiagnostics?: import('@cat-cafe/shared').CliDiagnostics } - | undefined; - if (meta?.cliDiagnostics && !collectedCliDiagnostics) { - collectedCliDiagnostics = meta.cliDiagnostics; - } - } + recordErrorMessage(effectiveMsg); if (effectiveMsg.metadata && !firstMetadata) { firstMetadata = effectiveMsg.metadata; } @@ -1617,7 +1798,7 @@ export async function* routeSerial( } else { const streamEvent = toStreamEvent(effectiveMsg); if (!streamEvent) continue; - if (needsServerRoutingGuard && streamEvent.type === 'text') { + if (needsBufferedGuard && streamEvent.type === 'text') { initialTextStreamEvents.push(streamEvent); } else { yield streamEvent; @@ -1668,6 +1849,9 @@ export async function* routeSerial( } let a2aMentions: CatId[] = []; + // F257 V1: attempt batch of the stream reply's a2a parse — embedded into the + // persisted message as its RoutingDecisionFact (T-A §3.4 / §4.5.1). + let a2aAttemptBatch: RoutingAttemptBatch | undefined; // F22: Consume MCP-buffered rich blocks BEFORE the text/empty branch — // blocks must be persisted even when the cat emits no text (cloud Codex P1). @@ -1676,19 +1860,23 @@ export async function* routeSerial( // F061: Detect @co-creator mentions in agent response for browser notification let mentionsUser = false; - const appendRoutingGuardFailureNotice = async () => { + const appendGuardFailureNotice = async (kind: 'routing' | 'action-liveness') => { try { + const isActionLiveness = kind === 'action-liveness'; const failureSource = { - connector: 'routing-guard-failure', - label: '路由守卫失败', + connector: isActionLiveness ? 'action-liveness-guard-failure' : 'routing-guard-failure', + label: isActionLiveness ? '动作活性守卫失败' : '路由守卫失败', icon: '🏓', meta: { presentation: 'system_notice', noticeTone: 'warning' }, }; const stored = await deps.messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, // sol R3 P1-1 userId: 'system', catId: null, threadId, - content: '[路由守卫]: 补救失败,第二次回复仍没有合法的路由出口;已停止自动重试以避免重复调用。', + content: isActionLiveness + ? '[动作活性守卫]: 补救失败,第二次回复仍没有真实动作或明确路由终态;已停止自动重试以避免重复调用。' + : '[路由守卫]: 补救失败,第二次回复仍没有合法的路由出口;已停止自动重试以避免重复调用。', mentions: [], timestamp: Date.now(), source: failureSource, @@ -1710,10 +1898,11 @@ export async function* routeSerial( } }; - const runRoutingGuardRemedial = async ( + const runGuardRemedial = async ( originalStoredContentBeforeRemedial: string, originalRichBlocksBeforeRemedial: RichBlock[], originalToolEventsBeforeRemedial: StoredToolEvent[], + remedialPrompt: string, ): Promise<{ storedContent: string; allRichBlocks: RichBlock[]; @@ -1722,8 +1911,8 @@ export async function* routeSerial( hasLocalCoCreatorLineStartMention: boolean; streamEvents: AgentMessage[]; }> => { - routingGuardAttempted = true; - routingGuardRemediated = true; + guardRemedialAttempted = true; + guardRemediated = true; const originalTextStreamEventsBeforeRemedial = [...initialTextStreamEvents]; const originalVisibleInvocationIdBeforeRemedial = ownInvocationId; const originalDeferredVoiceInvocationIdBeforeRemedial = deferredVoiceInvocationId; @@ -1741,6 +1930,7 @@ export async function* routeSerial( doneMsg = undefined; collectedToolEvents.splice(0, collectedToolEvents.length); collectedToolNames.splice(0, collectedToolNames.length); + confirmedCallbackToolNames.splice(0, confirmedCallbackToolNames.length); structuredTargetCats.clear(); streamRichBlocks.splice(0, streamRichBlocks.length); pendingToolResults.splice(0, pendingToolResults.length); @@ -1749,6 +1939,8 @@ export async function* routeSerial( confirmedLocalCallbackRoutingMentions.clear(); confirmedCallbackRoutingGuardHasCoCreatorLineStartMention = false; confirmedLocalCallbackRoutingHasCoCreatorLineStartMention = false; + confirmedStructuredTargetCats.clear(); + pendingStructuredTargetsByTool.clear(); callbackPostConfirmed = false; callbackPostMessageId = undefined; awaitingCallbackResult = false; @@ -1759,7 +1951,7 @@ export async function* routeSerial( for await (const remedialMsg of invokeSingleCat(deps.invocationDeps, { catId, service, - prompt: buildRemedialPrompt(), + prompt: remedialPrompt, userId, threadId, ...(catSignal ? { signal: catSignal } : {}), @@ -1846,9 +2038,15 @@ export async function* routeSerial( } if (effectiveMsg.type === 'tool_use') { - for (const target of collectStructuredTargetCatsFromInput(effectiveMsg.toolInput)) { + const targets = collectStructuredTargetCatsFromInput(effectiveMsg.toolInput); + for (const target of targets) { structuredTargetCats.add(target); } + // LI-005 P2-2: track pending targets for confirmation (retry/46 path) + if (targets.length > 0) { + const pendingKey = effectiveMsg.toolUseId ?? effectiveMsg.toolName ?? ''; + pendingStructuredTargetsByTool.set(pendingKey, targets); + } } if (effectiveMsg.type === 'tool_use' && effectiveMsg.toolName) { collectedToolNames.push(effectiveMsg.toolName); @@ -1891,9 +2089,32 @@ export async function* routeSerial( callbackResult.messageId, callbackResult.threadId, ); + // LI-005: durable trigger classification (same as primary handler above) + if ( + classifyDurableTriggerResult( + completedToolName.toolName, + effectiveMsg.content, + (effectiveMsg as { toolResultStatus?: 'ok' | 'error' | 'unknown' }).toolResultStatus, + ) + ) { + confirmedCallbackToolNames.push(completedToolName.toolName); + } else if (callbackResult.confirmed) { + confirmedCallbackToolNames.push(completedToolName.toolName); + } + // LI-005 P2-2: confirm pending structured targets (retry/46 path) + const pendingTargetKey = completedToolName.toolUseId ?? completedToolName.toolName; + const pendingTargets = pendingStructuredTargetsByTool.get(pendingTargetKey); + if (pendingTargets) { + if (callbackResult.confirmed) { + for (const t of pendingTargets) confirmedStructuredTargetCats.add(t); + } + pendingStructuredTargetsByTool.delete(pendingTargetKey); + } } } + recordErrorMessage(effectiveMsg); + if (effectiveMsg.metadata && !firstMetadata) { firstMetadata = effectiveMsg.metadata; } @@ -2015,39 +2236,56 @@ export async function* routeSerial( }; let noTextBlocksOverride: RichBlock[] | undefined; + const noTextGuardEvidence = { + lineStartMentions: getRoutingExitLineStartMentions(), + toolNames: collectedToolNames, + structuredTargetCats: [...structuredTargetCats], + hasCoCreatorLineStartMention: hasRoutingExitCoCreatorLineStartMention(''), + }; + const noTextNeedsRoutingRemedial = shouldRemediateRouting({ + ...noTextGuardEvidence, + needsGuard: needsServerRoutingGuard, + attempted: guardRemedialAttempted, + }); + const noTextNeedsActionRemedial = shouldRemediateActionLiveness({ + ...noTextGuardEvidence, + completionRequirement: needsActionLivenessGuard ? completionRequirement : undefined, + attempted: guardRemedialAttempted, + hadError, + aborted: catSignal?.aborted ?? false, + }); - if ( - !textContent && - !hadError && - shouldRemediateRouting({ - needsGuard: needsServerRoutingGuard, - attempted: routingGuardAttempted, - lineStartMentions: getRoutingExitLineStartMentions(), - toolNames: collectedToolNames, - structuredTargetCats: [...structuredTargetCats], - hasCoCreatorLineStartMention: hasRoutingExitCoCreatorLineStartMention(''), - }) - ) { - const result = await runRoutingGuardRemedial( + if (!textContent && !hadError && (noTextNeedsRoutingRemedial || noTextNeedsActionRemedial)) { + const result = await runGuardRemedial( '', [...bufferedBlocks, ...streamRichBlocks], [...collectedToolEvents], + noTextNeedsRoutingRemedial ? buildRemedialPrompt() : buildActionLivenessRemedialPrompt(), ); for (const event of result.streamEvents) yield event; await flushDeferredVoice(); noTextBlocksOverride = result.allRichBlocks; - if ( - !hasValidRoutingExit({ - lineStartMentions: getRoutingExitLineStartMentions(result.a2aMentions), - toolNames: collectedToolNames, - structuredTargetCats: [...structuredTargetCats], - hasCoCreatorLineStartMention: result.hasCoCreatorLineStartMention, - }) - ) { - await appendRoutingGuardFailureNotice(); + const finalNoTextEvidence = { + lineStartMentions: getRoutingExitLineStartMentions(result.a2aMentions), + toolNames: collectedToolNames, + structuredTargetCats: [...structuredTargetCats], + hasCoCreatorLineStartMention: result.hasCoCreatorLineStartMention, + }; + if (!hadError && !catSignal?.aborted) { + if (needsActionLivenessGuard && !hasActionOrRoutingExit(finalNoTextEvidence)) { + await appendGuardFailureNotice('action-liveness'); + } else if (needsServerRoutingGuard && !hasValidRoutingExit(finalNoTextEvidence)) { + await appendGuardFailureNotice('routing'); + } } } + // LI-005: A2A invocation signal — hoisted before text/no-text branch + // so ack-liveness evaluation covers both paths (Codex R1 P2-1 fix). + // directMessageFrom covers inline-serial A2A; queueTriggerReplyTo covers queue-dispatched A2A. + const isA2AInvocation = Boolean(directMessageFrom) || Boolean(queueTriggerReplyTo); + let pendingAckLivenessHint = false; + if (textContent) { catProducedOutput = true; const sanitized = sanitizeInjectedContent(textContent); @@ -2075,7 +2313,9 @@ export async function* routeSerial( // A2A mention detection (缅因猫 P1-3: only after full text accumulated) // Line-start @mention = always actionable (no keyword gate) - a2aMentions = parseA2AMentions(storedContent, catId); + const streamContentAnalysis = analyzeA2AMentions(storedContent, catId); + a2aMentions = streamContentAnalysis.mentions; + a2aAttemptBatch = streamContentAnalysis.attemptBatch; // clowder-ai#489: baseline counter — line-start mentions if (a2aMentions.length > 0) { @@ -2085,18 +2325,35 @@ export async function* routeSerial( let routingExitLineStartMentions = getRoutingExitLineStartMentions(a2aMentions); let routingExitHasCoCreatorLineStartMention = hasRoutingExitCoCreatorLineStartMention(storedContent); let localCvoHasCoCreatorLineStartMention = hasLocalCoCreatorLineStartMention(storedContent); - - if ( + const textGuardEvidence = { + lineStartMentions: routingExitLineStartMentions, + toolNames: collectedToolNames, + structuredTargetCats: [...structuredTargetCats], + hasCoCreatorLineStartMention: routingExitHasCoCreatorLineStartMention, + }; + const textNeedsRoutingRemedial = + !hadError && + !catSignal?.aborted && shouldRemediateRouting({ + ...textGuardEvidence, needsGuard: needsServerRoutingGuard, - attempted: routingGuardAttempted, - lineStartMentions: routingExitLineStartMentions, - toolNames: collectedToolNames, - structuredTargetCats: [...structuredTargetCats], - hasCoCreatorLineStartMention: routingExitHasCoCreatorLineStartMention, - }) - ) { - const result = await runRoutingGuardRemedial(storedContent, allRichBlocks, [...collectedToolEvents]); + attempted: guardRemedialAttempted, + }); + const textNeedsActionRemedial = shouldRemediateActionLiveness({ + ...textGuardEvidence, + completionRequirement: needsActionLivenessGuard ? completionRequirement : undefined, + attempted: guardRemedialAttempted, + hadError, + aborted: catSignal?.aborted ?? false, + }); + + if (textNeedsRoutingRemedial || textNeedsActionRemedial) { + const result = await runGuardRemedial( + storedContent, + allRichBlocks, + [...collectedToolEvents], + textNeedsRoutingRemedial ? buildRemedialPrompt() : buildActionLivenessRemedialPrompt(), + ); for (const event of result.streamEvents) yield event; await flushDeferredVoice(); storedContent = result.storedContent; @@ -2106,15 +2363,18 @@ export async function* routeSerial( routingExitHasCoCreatorLineStartMention = result.hasCoCreatorLineStartMention; localCvoHasCoCreatorLineStartMention = result.hasLocalCoCreatorLineStartMention; - if ( - !hasValidRoutingExit({ - lineStartMentions: routingExitLineStartMentions, - toolNames: collectedToolNames, - structuredTargetCats: [...structuredTargetCats], - hasCoCreatorLineStartMention: routingExitHasCoCreatorLineStartMention, - }) - ) { - await appendRoutingGuardFailureNotice(); + const finalTextEvidence = { + lineStartMentions: routingExitLineStartMentions, + toolNames: collectedToolNames, + structuredTargetCats: [...structuredTargetCats], + hasCoCreatorLineStartMention: routingExitHasCoCreatorLineStartMention, + }; + if (!hadError && !catSignal?.aborted) { + if (needsActionLivenessGuard && !hasActionOrRoutingExit(finalTextEvidence)) { + await appendGuardFailureNotice('action-liveness'); + } else if (needsServerRoutingGuard && !hasValidRoutingExit(finalTextEvidence)) { + await appendGuardFailureNotice('routing'); + } } } a2aMentions = getLocalRoutingLineStartMentions(a2aMentions); @@ -2126,7 +2386,7 @@ export async function* routeSerial( previousResponses.push({ catId, content: storedContent }); } - if (!routingGuardRemediated && initialTextStreamEvents.length > 0) { + if (!guardRemediated && initialTextStreamEvents.length > 0) { for (const event of initialTextStreamEvents) yield event; await flushDeferredVoice(); initialTextStreamEvents.splice(0, initialTextStreamEvents.length); @@ -2161,6 +2421,7 @@ export async function* routeSerial( meta: { presentation: 'system_notice', noticeTone: 'warning' }, }; const stored = await deps.messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, // sol R3 P1-1 userId: 'system', catId: null, threadId, @@ -2228,6 +2489,7 @@ export async function* routeSerial( meta: { presentation: 'system_notice', noticeTone: 'info' }, }; const stored = await deps.messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, // sol R3 P1-1 userId: 'system', catId: null, threadId, @@ -2313,6 +2575,7 @@ export async function* routeSerial( meta: { presentation: 'system_notice', noticeTone: 'warning' }, }; const stored = await deps.messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, // sol R3 P1-1 userId: 'system', catId: null, threadId, @@ -2375,6 +2638,7 @@ export async function* routeSerial( meta: { presentation: 'system_notice', noticeTone: 'warning' }, }; const voidStored = await deps.messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, // sol R3 P1-1 userId: 'system', catId: null, threadId, @@ -2409,6 +2673,62 @@ export async function* routeSerial( } } + // LI-005 Phase 1: A2A ack-liveness detection (text path). + // isA2AInvocation and pendingAckLivenessHint are hoisted before the + // text/no-text branch (Codex R1 P2-1 fix). + if (isA2AInvocation) { + c2AckLivenessChecked.add(1, c2BaseAttr); + // LI-005: all providers now emit tool_result (Claude CLI bridge + // added in R4). Only confirmed-successful durable triggers suppress the hint. + // LI-005 P2-2: use confirmedStructuredTargetCats (not unconfirmed structuredTargetCats). + const ackLivenessEval = evaluateAckLiveness({ + isA2AInvocation, + toolNames: confirmedCallbackToolNames, + lineStartMentions: routingExitLineStartMentions, + structuredTargetCats: [...confirmedStructuredTargetCats], + hasCoCreatorLineStartMention: routingExitHasCoCreatorLineStartMention, + }); + if (ackLivenessEval.shouldEmit) { + pendingAckLivenessHint = true; + try { + const hintSource = { + connector: 'ack-liveness-hint', + label: '接球提醒', + icon: '🏓', + meta: { presentation: 'system_notice', noticeTone: 'warning' }, + }; + const ackStored = await deps.messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, // sol R3 P1-1 + userId: 'system', + catId: null, + threadId, + content: + '[接球提醒]: A2A 接球后 invocation 结束,但未绑定任何持久触发器' + + '(hold_ball / register_scheduled_task 等)也未传球给下一只猫 — ' + + '球将静默死亡。请调用 `cat_cafe_hold_ball` 持球或行首 `@句柄` 传球。', + mentions: [], + timestamp: Date.now(), + source: hintSource, + }); + c2AckLivenessHintEmitted.add(1, c2BaseAttr); + if (deps.socketManager) { + deps.socketManager.broadcastToRoom(`thread:${threadId}`, 'connector_message', { + threadId, + message: { + id: ackStored.id, + type: 'connector', + content: ackStored.content, + source: hintSource, + timestamp: ackStored.timestamp, + }, + }); + } + } catch { + /* non-blocking hint */ + } + } + } + // F079 Phase 2: Vote interception — extract [VOTE:xxx] from cat response const votedOption = extractVoteFromText(storedContent); if (votedOption && deps.invocationDeps.threadStore) { @@ -2454,6 +2774,7 @@ export async function* routeSerial( // Gap 3: persist separate connector message for ConnectorBubble rendering try { const stored = await deps.messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, // sol R3 P1-1 userId, catId: null, content: `投票结果: ${voteState.question}`, @@ -2571,6 +2892,13 @@ export async function* routeSerial( origin: 'stream', timestamp: storedTimestamp, threadId, + // F257 V1 (T-A §3.4 / §4.5.1); lane provenance per sol R2 P1-1 + ...(a2aAttemptBatch + ? { + routingFact: a2aAttemptBatch, + provenance: { author: 'cat' as const, routed: true, observation: 'original' }, + } + : { provenance: { author: 'cat' as const, routed: false, observation: 'original' } }), ...(mentionsUser ? { mentionsUser } : {}), ...(thinkingChunks.length > 0 ? { thinking: renderThinkingChunks(thinkingChunks) } : {}), ...(firstMetadata ? { metadata: firstMetadata } : {}), @@ -2594,6 +2922,9 @@ export async function* routeSerial( } : {}), ...(doneMsg?.tracing ? { tracing: doneMsg.tracing } : {}), + // F257 #4 (sol R1 P1-1): stamp signature lint on the ordinary agent + // stream-final so it enters the sign-rate denominator, not just callback posts. + ...signatureLintExtra(storedContent), }, }); storedMsgId = storedMsg.id; @@ -2763,6 +3094,12 @@ export async function* routeSerial( // F233 Phase B (B2): 同一虚空传球旁路写 ball.void_pass(storedMsgId 此时已绑定) emitBallVoidPass(deps.ballCustody, threadId, storedMsgId, pendingC2VoidHoldSampleTrigger); } + + // LI-005: deferred ball.void_ack emission(storedMsgId 此时已绑定) + // streamReplyTo = trigger message ID covering both inline serial and queue paths + if (pendingAckLivenessHint && storedMsgId) { + emitBallVoidAck(deps.ballCustody, threadId, storedMsgId, streamReplyTo); + } } catch (err) { log.error({ catId: catId as string, err }, 'messageStore.append failed, degrading'); if (options.persistenceContext) { @@ -2852,6 +3189,30 @@ export async function* routeSerial( 'A2A text-scan dedup: cat actively processing in InvocationQueue, skipping', ); } + // F257 V2: emit route_decision_skip (fail-open) — swallowed + // mentions are otherwise invisible ("@ed but nothing happened"). + if (deps.guardRejectionLog) { + deps.guardRejectionLog + .append({ + eventId: crypto.randomUUID(), + ledgerId: ledgerIdForGuard('a2a_route_decision_skip'), + kind: 'route_decision_skip', + threadId, + catId: catId as string, + guardId: 'a2a_route_decision_skip', + ownerUserId: userId, + invocationId: 'unknown', + sourceTool: 'a2a_mention', + normalizedReason: decision.reason ?? 'unspecified', + layer: 'generator', + timestamp: Date.now(), + correlationConfidence: 'window', + fromCatId: catId as string, + targetCatId: nextCat, + skipReason: decision.reason ?? 'unspecified', + }) + .catch(() => {}); + } continue; } if (decision.action === 'mark_replyto') { @@ -2885,6 +3246,29 @@ export async function* routeSerial( }), timestamp: Date.now(), } as AgentMessage; + // F257: emit route_decision_block event (fail-open, fire-and-forget) + if (deps.guardRejectionLog) { + deps.guardRejectionLog + .append({ + eventId: crypto.randomUUID(), + ledgerId: ledgerIdForGuard('a2a_block_pingpong'), + kind: 'route_decision_block', + threadId, + catId: catId as string, + guardId: 'a2a_block_pingpong', + ownerUserId: userId, + invocationId: 'unknown', + sourceTool: 'a2a_mention', + normalizedReason: decision.reason, + layer: 'generator', + timestamp: Date.now(), + correlationConfidence: 'window', + fromCatId: catId as string, + targetCatId: nextCat, + streakCount: streak.count, + }) + .catch(() => {}); + } continue; } @@ -3024,6 +3408,29 @@ export async function* routeSerial( 'A2A text-scan dedup (deferred): cat actively processing, skipping', ); } + // F257 V2: emit route_decision_skip for deferred path (fail-open). + if (deps.guardRejectionLog) { + deps.guardRejectionLog + .append({ + eventId: crypto.randomUUID(), + ledgerId: ledgerIdForGuard('a2a_route_decision_skip'), + kind: 'route_decision_skip', + threadId, + catId: catId as string, + guardId: 'a2a_route_decision_skip', + ownerUserId: userId, + invocationId: 'unknown', + sourceTool: 'a2a_mention', + normalizedReason: decision.reason ?? 'unspecified', + layer: 'generator', + timestamp: Date.now(), + correlationConfidence: 'window', + fromCatId: catId as string, + targetCatId: nextCat, + skipReason: decision.reason ?? 'unspecified', + }) + .catch(() => {}); + } continue; } if (decision.action === 'mark_replyto') { @@ -3054,6 +3461,29 @@ export async function* routeSerial( }), timestamp: Date.now(), } as AgentMessage; + // F257: emit route_decision_block for deferred path (fail-open) + if (deps.guardRejectionLog) { + deps.guardRejectionLog + .append({ + eventId: crypto.randomUUID(), + ledgerId: ledgerIdForGuard('a2a_block_pingpong'), + kind: 'route_decision_block', + threadId, + catId: catId as string, + guardId: 'a2a_block_pingpong', + ownerUserId: userId, + invocationId: 'unknown', + sourceTool: 'a2a_mention', + normalizedReason: decision.reason, + layer: 'generator', + timestamp: Date.now(), + correlationConfidence: 'window', + fromCatId: catId as string, + targetCatId: nextCat, + streakCount: streakDeferred.count, + }) + .catch(() => {}); + } continue; } // decision.action === 'defer_queue' @@ -3152,7 +3582,7 @@ export async function* routeSerial( // No text content and no error. // Persist only when we have non-text payload (tool/thinking/rich). // Purely empty turns should not create blank chat bubbles. - if (!routingGuardRemediated && initialTextStreamEvents.length > 0) { + if (!guardRemediated && initialTextStreamEvents.length > 0) { for (const event of initialTextStreamEvents) yield event; initialTextStreamEvents.splice(0, initialTextStreamEvents.length); } @@ -3198,6 +3628,8 @@ export async function* routeSerial( if (shouldPersistNoTextMessage) { try { await deps.messageStore.append({ + routingFact: analyzeA2AMentions('', catId).attemptBatch, // F257 zero-token marker (T-A) + provenance: { author: 'cat', routed: true, observation: 'original' }, // sol R3 P1-1 userId, catId, content: '', @@ -3284,6 +3716,8 @@ export async function* routeSerial( // refreshing the page still shows what the cat attempted before the error. try { await deps.messageStore.append({ + routingFact: analyzeA2AMentions('', catId).attemptBatch, // F257 zero-token marker (T-A) + provenance: { author: 'cat', routed: true, observation: 'original' }, // sol R3 P1-1 userId, catId, content: '', @@ -3354,7 +3788,68 @@ export async function* routeSerial( } } - if (!routingGuardRemediated && initialTextStreamEvents.length > 0) { + // LI-005: ack-liveness for no-text A2A turns (Codex R1 P2-1 fix). + // Covers both the tool-only branch (else-if) and the error-only branch (else). + // In no-text turns: no line-start mentions from text, only confirmed callback data. + // The text path evaluates ack-liveness inside its own block; this only fires + // when textContent is falsy to avoid double evaluation. + if (!textContent && isA2AInvocation) { + const noTextC2Attr: Record = { + [AGENT_ID]: catId as string, + [THREAD_SYSTEM_KIND]: routeThread?.systemKind ?? 'product', + }; + c2AckLivenessChecked.add(1, noTextC2Attr); + const noTextAckEval = evaluateAckLiveness({ + isA2AInvocation, + toolNames: confirmedCallbackToolNames, + lineStartMentions: getRoutingExitLineStartMentions([]), + structuredTargetCats: [...confirmedStructuredTargetCats], + hasCoCreatorLineStartMention: confirmedCallbackRoutingGuardHasCoCreatorLineStartMention, + }); + if (noTextAckEval.shouldEmit) { + pendingAckLivenessHint = true; + try { + const hintSource = { + connector: 'ack-liveness-hint', + label: '接球提醒', + icon: '🏓', + meta: { presentation: 'system_notice', noticeTone: 'warning' }, + }; + const ackStored = await deps.messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, // sol R3 P1-1 + userId: 'system', + catId: null, + threadId, + content: + '[接球提醒]: A2A 接球后 invocation 结束,但未绑定任何持久触发器' + + '(hold_ball / register_scheduled_task 等)也未传球给下一只猫 — ' + + '球将静默死亡。请调用 `cat_cafe_hold_ball` 持球或行首 `@句柄` 传球。', + mentions: [], + timestamp: Date.now(), + source: hintSource, + }); + c2AckLivenessHintEmitted.add(1, noTextC2Attr); + if (deps.socketManager) { + deps.socketManager.broadcastToRoom(`thread:${threadId}`, 'connector_message', { + threadId, + message: { + id: ackStored.id, + type: 'connector', + content: ackStored.content, + source: hintSource, + timestamp: ackStored.timestamp, + }, + }); + } + // void_ack: anchor to hint message (no cat response message in no-text path) + emitBallVoidAck(deps.ballCustody, threadId, ackStored.id, streamReplyTo); + } catch { + /* non-blocking hint */ + } + } + } + + if (!guardRemediated && initialTextStreamEvents.length > 0) { for (const event of initialTextStreamEvents) yield event; initialTextStreamEvents.splice(0, initialTextStreamEvents.length); } @@ -3410,6 +3905,7 @@ export async function* routeSerial( if (collectedErrorText) { try { await deps.messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, // sol R3 P1-1 userId: 'system', catId: null, content: `Error: ${collectedErrorText}`, diff --git a/packages/api/src/domains/cats/services/agents/routing/routing-attempt.ts b/packages/api/src/domains/cats/services/agents/routing/routing-attempt.ts new file mode 100644 index 0000000000..f802a193ec --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/routing/routing-attempt.ts @@ -0,0 +1,188 @@ +/** + * F257 V1 — RoutingAttemptDraft types + collector. + * + * Semantics single source of truth: T-A decision table (§3.4) in + * docs/features/assets/F257/objective-driven-redesign-v1.md. + * This module encodes the table's columns as code and intentionally does NOT + * restate their definitions (§0 doc-architecture rule). attemptId = + * (messageId, parserMode, tokenOrdinal) is finalized by the persistence layer + * after MessageStore assigns messageId — never re-tokenized outside the parser. + */ + +import type { CatId } from '@cat-cafe/shared'; + +export const ROUTING_PARSER_MODES = ['a2a', 'user'] as const; +export type RoutingParserMode = (typeof ROUTING_PARSER_MODES)[number]; + +export const ROUTING_ATTEMPT_OUTCOMES = [ + 'resolved', + 'disabled_cat', + 'self_excluded', + 'unknown_token', + 'duplicate', + 'group_keyword_skip', + 'domain_suffixed_skip', + // F257 #1 (dev-628ea4d1): pattern matched but is held by >1 cat — routing + // refuses to guess. No single targetCatId by construction. + 'ambiguous', +] as const; +export type RoutingAttemptOutcome = (typeof ROUTING_ATTEMPT_OUTCOMES)[number]; + +/** Coordinate basis of the spans in a batch (the parser's scan text). */ +export const ROUTING_SPAN_BASES = ['a2a_normalized', 'lowercased_message'] as const; +export type RoutingSpanBasis = (typeof ROUTING_SPAN_BASES)[number]; + +export interface RoutingTokenSpan { + readonly start: number; + readonly end: number; +} + +export interface RoutingAttemptDraft { + /** 0-based; assigned once in finalize() after all scan passes merge (T-A §3.4 header). */ + readonly tokenOrdinal: number; + readonly outcome: RoutingAttemptOutcome; + /** Raw token text as scanned, e.g. "@opus". */ + readonly token: string; + readonly span: RoutingTokenSpan; + readonly targetCatId?: CatId; +} + +export interface RoutingAttemptBatch { + readonly parserMode: RoutingParserMode; + readonly spanBasis: RoutingSpanBasis; + readonly attempts: readonly RoutingAttemptDraft[]; + /** T-A (右截断) row: true only when the read-only scan confirmed extra metric-affecting tokens. */ + readonly truncated: boolean; + readonly metricEligible: boolean; +} + +/** + * Outcomes whose emit sites ALWAYS attach the matched pattern's catId + * (a2a-mentions emitA2AAttempt pattern path; AgentRouter record* helpers). + * The complement (unknown_token / group_keyword_skip / domain_suffixed_skip / + * ambiguous — the latter has multiple holders, hence no single target) + * never has a target. Derived from the emit sites — keep in sync with them. + */ +const TARGET_REQUIRED_OUTCOMES: ReadonlySet = new Set([ + 'resolved', + 'disabled_cat', + 'duplicate', + 'self_excluded', +]); + +/** + * Full validation of a persisted batch (sol R1 P1-3 fields + sol R2 P1-2 + * cross-field invariants). Every invariant checked here is guaranteed by + * RoutingAttemptCollector construction — a structurally-typed batch violating + * any of them was NOT produced by the parser and must be treated as malformed + * (partial counting from such a batch biases the exact metric). + * Lives next to the type so schema and validator cannot drift. + */ +export function isValidRoutingAttemptBatch(value: unknown): value is RoutingAttemptBatch { + if (!value || typeof value !== 'object') return false; + const batch = value as Record; + if (!(ROUTING_PARSER_MODES as readonly string[]).includes(batch.parserMode as string)) return false; + if (!(ROUTING_SPAN_BASES as readonly string[]).includes(batch.spanBasis as string)) return false; + // Each finalize call site hardcodes its own basis — the pairing is fixed (sol R3 P1-3). + const expectedBasis = batch.parserMode === 'a2a' ? 'a2a_normalized' : 'lowercased_message'; + if (batch.spanBasis !== expectedBasis) return false; + if (typeof batch.truncated !== 'boolean' || typeof batch.metricEligible !== 'boolean') return false; + // finalize(): metricEligible = !truncated, unconditionally. + if (batch.metricEligible !== !batch.truncated) return false; + // Only the a2a scanner has a cap; the user parser never passes truncated. + if (batch.parserMode === 'user' && batch.truncated) return false; + if (!Array.isArray(batch.attempts)) return false; + if (!batch.attempts.every((attempt) => isValidRoutingAttemptDraft(attempt))) return false; + + // finalize(): sort by (span.start, span.end) then assign tokenOrdinal = index; + // collector dedups spans, so the (start, end) sequence is STRICTLY increasing. + const attempts = batch.attempts as Array<{ tokenOrdinal: number; span: { start: number; end: number } }>; + for (let i = 0; i < attempts.length; i += 1) { + if (attempts[i].tokenOrdinal !== i) return false; + if (i > 0) { + // Scan passes match disjoint text regions and the collector dedups spans — + // tokens are non-overlapping in scan order (sol R3 P1-3: stronger than + // mere lexicographic increase; round-trip tests pin real parser output). + if (attempts[i].span.start < attempts[i - 1].span.end) return false; + } + } + return true; +} + +function isValidRoutingAttemptDraft(value: unknown): boolean { + if (!value || typeof value !== 'object') return false; + const draft = value as Record; + if (!Number.isInteger(draft.tokenOrdinal) || (draft.tokenOrdinal as number) < 0) return false; + if (!(ROUTING_ATTEMPT_OUTCOMES as readonly string[]).includes(draft.outcome as string)) return false; + if (typeof draft.token !== 'string' || draft.token.length === 0) return false; + const span = draft.span as Record | undefined; + if (!span || typeof span !== 'object') return false; + if (!Number.isInteger(span.start) || !Number.isInteger(span.end)) return false; + if ((span.start as number) < 0 || (span.end as number) <= (span.start as number)) return false; + // sol R3 P1-3: a present-but-empty target would enter the exact numerator. + if (draft.targetCatId !== undefined && (typeof draft.targetCatId !== 'string' || draft.targetCatId.length === 0)) { + return false; + } + // sol R2 P1-2: emit sites attach a target for pattern-matched outcomes and + // never for the token-skip outcomes — a mismatch cannot come from the parser. + const requiresTarget = TARGET_REQUIRED_OUTCOMES.has(draft.outcome as string); + if (requiresTarget !== (draft.targetCatId !== undefined)) return false; + return true; +} + +/** + * T-A eligible column (进分母). + * 'ambiguous' counts toward the denominator (F257 #1): the sender authored a + * real routing attempt that the system refused to resolve — excluding it would + * overstate @解析成功率 exactly when collisions are hurting routing. + */ +export function isMetricEligibleOutcome(outcome: RoutingAttemptOutcome): boolean { + return ( + outcome === 'resolved' || + outcome === 'disabled_cat' || + outcome === 'self_excluded' || + outcome === 'unknown_token' || + outcome === 'ambiguous' + ); +} + +/** T-A success column. */ +export function isSuccessOutcome(outcome: RoutingAttemptOutcome): boolean { + return outcome === 'resolved'; +} + +interface PendingRoutingDraft { + readonly outcome: RoutingAttemptOutcome; + readonly token: string; + readonly span: RoutingTokenSpan; + readonly targetCatId?: CatId; +} + +/** + * Collects drafts across scan passes with span-level dedup: a span visited a + * second time is a traversal artifact and merges silently — the first outcome + * wins and no new draft is produced (T-A attempt-stream uniqueness contract). + */ +export class RoutingAttemptCollector { + private readonly drafts: PendingRoutingDraft[] = []; + private readonly seenSpans = new Set(); + + add(span: RoutingTokenSpan, token: string, outcome: RoutingAttemptOutcome, targetCatId?: CatId): void { + const key = `${span.start}:${span.end}`; + if (this.seenSpans.has(key)) return; + this.seenSpans.add(key); + this.drafts.push(targetCatId === undefined ? { outcome, token, span } : { outcome, token, span, targetCatId }); + } + + finalize( + parserMode: RoutingParserMode, + spanBasis: RoutingSpanBasis, + opts?: { truncated?: boolean }, + ): RoutingAttemptBatch { + const truncated = opts?.truncated ?? false; + const attempts = [...this.drafts] + .sort((a, b) => a.span.start - b.span.start || a.span.end - b.span.end) + .map((draft, index) => ({ ...draft, tokenOrdinal: index })); + return { parserMode, spanBasis, attempts, truncated, metricEligible: !truncated }; + } +} diff --git a/packages/api/src/domains/cats/services/agents/routing/routing-decision.ts b/packages/api/src/domains/cats/services/agents/routing/routing-decision.ts index 8d4c353ba9..47195b807f 100644 --- a/packages/api/src/domains/cats/services/agents/routing/routing-decision.ts +++ b/packages/api/src/domains/cats/services/agents/routing/routing-decision.ts @@ -25,8 +25,8 @@ export type RoutingDecision = | { action: 'enqueue_worklist'; cat: CatId } // 执行层:worklist.push + updateStreakOnPush + span | { action: 'defer_queue'; cat: CatId } // 执行层:deferA2AEnqueue(排到非-agent 之后) | { action: 'mark_replyto'; cat: CatId } // pendingTail 命中且非原始 target:只设 a2aFrom/triggerMsg,不 push - | { action: 'skip'; cat: CatId; reason: 'depth' | 'dedup_active' | 'aborted' | 'queue_pending' } - | { action: 'block_pingpong'; cat: CatId; pairCount: number }; // 执行层:yield a2a_pingpong_terminated + | { action: 'skip'; cat: CatId; reason: 'depth' | 'dedup_active' | 'aborted' } + | { action: 'block_pingpong'; cat: CatId; pairCount: number; reason: 'pingpong_streak' }; // 执行层:yield a2a_pingpong_terminated /** 决策所需的只读上下文快照(不 mutate)。 */ export interface RoutingContext { @@ -105,7 +105,7 @@ function resolveInlineCat(cat: CatId, ctx: RoutingContext, depth: number): Routi } // Ping-pong breaker (read-only预判; execution layer does the real updateStreakOnPush mutate). const streak = ctx.peekStreak(cat); - if (streak.wouldBlock) return { action: 'block_pingpong', cat, pairCount: streak.count }; + if (streak.wouldBlock) return { action: 'block_pingpong', cat, pairCount: streak.count, reason: 'pingpong_streak' }; // F216 c2: queue fairness gate is the LAST check, AFTER depth/dedup/pendingTail/streak. This way the // deferred path (queuedMessagesPending=true) still runs the full guard chain before deferring — it // gets skip:depth / skip:dedup_active / mark_replyto / block_pingpong exactly like inline, then diff --git a/packages/api/src/domains/cats/services/agents/routing/speech-mention-map.ts b/packages/api/src/domains/cats/services/agents/routing/speech-mention-map.ts new file mode 100644 index 0000000000..67a03bcf1a --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/routing/speech-mention-map.ts @@ -0,0 +1,90 @@ +/** + * F257 V1 — offset-mapped speech mention normalization. + * + * The speech pass re-scans a transformed variant of the message ("at 砚砚" → + * "@砚砚"), so token positions shift relative to the raw message. To keep the + * T-A (§3.4) attempt-stream uniqueness contract (one draft per unique source + * span; a re-visited span is a traversal artifact that merges silently), the + * speech pass must express its drafts in raw message coordinates. This module + * produces the exact same normalized text as the previous + * `String.replace`-based implementation plus a span mapper back to the raw + * message. + */ + +interface SpeechMapSegment { + readonly outStart: number; + readonly outEnd: number; + readonly rawStart: number; + readonly rawEnd: number; + /** identity segments map 1:1 by offset; replaced segments map to their whole raw region */ + readonly identity: boolean; +} + +export interface SpeechTokenSpan { + readonly start: number; + readonly end: number; +} + +export interface SpeechNormalization { + /** Normalized text (same output as the legacy speech replace). */ + readonly text: string; + /** Map a span in normalized-text coordinates back to raw message coordinates. */ + readonly mapSpanToRaw: (span: SpeechTokenSpan) => SpeechTokenSpan; +} + +export function normalizeSpeechMentionsWithMap(message: string, speechMentionRe: RegExp): SpeechNormalization { + const segments: SpeechMapSegment[] = []; + let out = ''; + let rawCursor = 0; + + const pushIdentity = (rawEnd: number): void => { + if (rawEnd <= rawCursor) return; + segments.push({ + outStart: out.length, + outEnd: out.length + (rawEnd - rawCursor), + rawStart: rawCursor, + rawEnd, + identity: true, + }); + out += message.slice(rawCursor, rawEnd); + rawCursor = rawEnd; + }; + + for (const match of message.matchAll(speechMentionRe)) { + const index = match.index ?? 0; + const prefix = match[1] ?? ''; + const mention = match[2] ?? ''; + // Legacy replacement was `${prefix}@${mention}` — the prefix survives as + // identity text; only the region after it is rewritten. + pushIdentity(index + prefix.length); + const replacement = `@${mention}`; + segments.push({ + outStart: out.length, + outEnd: out.length + replacement.length, + rawStart: rawCursor, + rawEnd: index + match[0].length, + identity: false, + }); + out += replacement; + rawCursor = index + match[0].length; + } + pushIdentity(message.length); + + return { + text: out, + mapSpanToRaw: (span) => ({ + start: mapOutputPosToRaw(segments, span.start, false), + end: mapOutputPosToRaw(segments, span.end, true), + }), + }; +} + +function mapOutputPosToRaw(segments: readonly SpeechMapSegment[], pos: number, isEnd: boolean): number { + const probe = isEnd ? pos - 1 : pos; + for (const seg of segments) { + if (probe < seg.outStart || probe >= seg.outEnd) continue; + if (seg.identity) return seg.rawStart + (probe - seg.outStart) + (isEnd ? 1 : 0); + return isEnd ? seg.rawEnd : seg.rawStart; + } + return pos; // defensive: out-of-range positions map through unchanged +} diff --git a/packages/api/src/domains/cats/services/context/prompt-template-loader.ts b/packages/api/src/domains/cats/services/context/prompt-template-loader.ts index 7693e9b668..8cad5f2f45 100644 --- a/packages/api/src/domains/cats/services/context/prompt-template-loader.ts +++ b/packages/api/src/domains/cats/services/context/prompt-template-loader.ts @@ -14,6 +14,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; +import type { HookVariableDef } from '@cat-cafe/shared'; import YAML from 'yaml'; import { findMonorepoRoot } from '../../../../utils/monorepo-root.js'; @@ -73,36 +74,31 @@ export function stripComments(content: string): string { * Checks for workflow-triggers.local.yaml overlay first. * Returns Record keyed by breedId. */ -export function loadWorkflowTriggers(): Record { - const { path: filePath, isOverride } = resolveWithOverlay('workflow-triggers.yaml', 'workflow-triggers.local.yaml'); - if (!existsSync(filePath)) { - console.warn('[prompt-template] workflow-triggers.yaml not found, using empty map'); - return {}; - } - - let parsed: unknown; +function parseYamlFile(filePath: string): unknown | undefined { try { - parsed = YAML.parse(readFileSync(filePath, 'utf-8')); + return YAML.parse(readFileSync(filePath, 'utf-8')); } catch (err) { console.warn(`[prompt-template] malformed YAML in ${filePath}: ${err}`); - // Bad overlay → fall back to base; bad base → empty map - if (isOverride) { - const basePath = templatePath('workflow-triggers.yaml'); - if (existsSync(basePath)) { - try { - parsed = YAML.parse(readFileSync(basePath, 'utf-8')); - } catch { - console.warn('[prompt-template] base workflow-triggers.yaml also malformed, using empty map'); - return {}; - } - } else { - return {}; - } - } else { - return {}; - } + return undefined; + } +} + +function parseWorkflowTriggers(filePath: string, isOverride: boolean): unknown | undefined { + const parsed = parseYamlFile(filePath); + if (parsed !== undefined) return parsed; + if (!isOverride) return undefined; + + const basePath = templatePath('workflow-triggers.yaml'); + if (!existsSync(basePath)) return undefined; + + const baseParsed = parseYamlFile(basePath); + if (baseParsed === undefined) { + console.warn('[prompt-template] base workflow-triggers.yaml also malformed, using empty map'); } + return baseParsed; +} +function extractWorkflowTriggers(parsed: unknown): Record { if (parsed == null || typeof parsed !== 'object') return {}; // YAML block scalars have trailing newline — trim to match original .join('\n') output @@ -115,6 +111,17 @@ export function loadWorkflowTriggers(): Record { return result; } +export function loadWorkflowTriggers(): Record { + const { path: filePath, isOverride } = resolveWithOverlay('workflow-triggers.yaml', 'workflow-triggers.local.yaml'); + if (!existsSync(filePath)) { + console.warn('[prompt-template] workflow-triggers.yaml not found, using empty map'); + return {}; + } + + const parsed = parseWorkflowTriggers(filePath, isOverride); + return parsed === undefined ? {} : extractWorkflowTriggers(parsed); +} + // ── S13: MCP Tools Section (allowLocalOverride: true) ──────── /** @@ -173,8 +180,11 @@ export interface OverrideStatus { /** Known template-backed segments and their file mappings. * Tier A (F237 template unification): simple {{VAR}} substitution. - * Existing: S6, S13, D8, D21. New Tier A: S1, S2, S8, D1, D5, D9-D11, D14, D16. */ -const TEMPLATE_FILES: Record = { + * Existing: S6, S13, D8, D21. New Tier A: S1, S2, S8, D1, D5, D9-D11, D14, D16. + * Exported so parity checks between placeholders and hook manifest variables + * can be enforced as a fail-closed invariant (F257 Console 判据⑤). + */ +export const TEMPLATE_FILES: Record = { // ── L0 section templates (compiled by compile-system-prompt-l0.mjs) ── L1: { base: 'l1-parallel-world.md', local: '' }, L2: { base: 'l2-carry-over.md', local: '' }, @@ -184,8 +194,26 @@ const TEMPLATE_FILES: Record = { L6: { base: 'l6-capability-wakeup.md', local: '' }, L7: { base: 'l7-collaboration-philosophy.md', local: '' }, // ── Non-Builder segments (M/C/N/B — migrated to template) ── - M1: { base: 'm1-dispatch-mission.md', local: '' }, - M2: { base: 'm2-transcript-hints.md', local: '' }, + M1: { + base: 'm1-dispatch-mission.md', + local: '', + variables: [ + { name: 'MISSION', description: '当前任务名称' }, + { name: 'WORK_ITEM', description: '当前工作项' }, + { name: 'PHASE', description: '当前阶段' }, + { name: 'DONE_WHEN_BLOCK', description: '完成条件块' }, + { name: 'LINKS_BLOCK', description: '相关链接块' }, + ], + }, + M2: { + base: 'm2-transcript-hints.md', + local: '', + variables: [ + { name: 'TRANSCRIPT_PATH', description: '会议转录文件路径' }, + { name: 'LATEST_RANGE_LINE', description: '最新时间范围行' }, + { name: 'PARTICIPANTS_LINE', description: '参会者行' }, + ], + }, C1: { base: 'c1-mcp-callback.md', local: 'c1-mcp-callback.local.md' }, N1: { base: 'n1-navigation.md', local: '' }, // ── Existing templates ── @@ -211,8 +239,22 @@ const TEMPLATE_FILES: Record = { D4: { base: 'd4-cross-thread-reply.md', local: '' }, D6: { base: 'd6-teammates.md', local: '' }, D7: { base: 'd7-mode-serial.md', local: '' }, // F237: default variant for manifest D7 viewing - D7_serial: { base: 'd7-mode-serial.md', local: '' }, - D7_parallel: { base: 'd7-mode-parallel.md', local: '' }, + D7_serial: { + base: 'd7-mode-serial.md', + local: '', + variables: [ + { name: 'CHAIN_INDEX', description: '串行链中的当前猫序号' }, + { name: 'CHAIN_TOTAL', description: '串行链中的猫总数' }, + ], + }, + D7_parallel: { + base: 'd7-mode-parallel.md', + local: '', + variables: [ + { name: 'DISPLAY_NAME', description: '当前猫的显示名' }, + { name: 'CAT_ID', description: '当前猫的稳定 ID' }, + ], + }, D7_solo: { base: 'd7-mode-solo.md', local: '' }, D12: { base: 'd12-active-participant.md', local: '' }, D13: { base: 'd13-routing-policy.md', local: '' }, @@ -274,7 +316,9 @@ export function getTemplateRawContent(segmentId: string, useOverride: boolean): } /** Get the base filename for a template-backed segment */ -export function getTemplateFileInfo(segmentId: string): { base: string; local: string } | null { +export function getTemplateFileInfo( + segmentId: string, +): { base: string; local: string; variables?: HookVariableDef[] } | null { return TEMPLATE_FILES[segmentId] ?? null; } diff --git a/packages/api/src/domains/cats/services/duty-briefing/briefing-delivery.ts b/packages/api/src/domains/cats/services/duty-briefing/briefing-delivery.ts index 4a9c3099c1..34a8a741d9 100644 --- a/packages/api/src/domains/cats/services/duty-briefing/briefing-delivery.ts +++ b/packages/api/src/domains/cats/services/duty-briefing/briefing-delivery.ts @@ -24,6 +24,7 @@ export async function deliverBriefingCard( ): Promise { const rich: RichMessageExtra = { v: 1, blocks: [card] }; const msg = await messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, // sol R3 P1-1 threadId, userId: BRIEFING_USER_ID, catId: null, diff --git a/packages/api/src/domains/cats/services/frustration/FrustrationDetector.ts b/packages/api/src/domains/cats/services/frustration/FrustrationDetector.ts index 98e749c3fe..3b4fc50dd9 100644 --- a/packages/api/src/domains/cats/services/frustration/FrustrationDetector.ts +++ b/packages/api/src/domains/cats/services/frustration/FrustrationDetector.ts @@ -292,6 +292,7 @@ export async function evaluate( // 7. Post as system message with rich blocks try { const stored = await deps.messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, // sol R3 P1-1 userId: 'system', catId: null, threadId, diff --git a/packages/api/src/domains/cats/services/game/GameOrchestrator.ts b/packages/api/src/domains/cats/services/game/GameOrchestrator.ts index db40ca7f61..2783462aa3 100644 --- a/packages/api/src/domains/cats/services/game/GameOrchestrator.ts +++ b/packages/api/src/domains/cats/services/game/GameOrchestrator.ts @@ -335,6 +335,7 @@ export class GameOrchestrator { const userId = runtime.config.observerUserId ?? 'system'; Promise.resolve( this.messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, // sol R3 P1-1 userId, catId: catId as import('@cat-cafe/shared').CatId, content, diff --git a/packages/api/src/domains/cats/services/game/gameSystemMessage.ts b/packages/api/src/domains/cats/services/game/gameSystemMessage.ts index 5b2dd11572..699798f344 100644 --- a/packages/api/src/domains/cats/services/game/gameSystemMessage.ts +++ b/packages/api/src/domains/cats/services/game/gameSystemMessage.ts @@ -16,6 +16,7 @@ export async function appendGameSystemMessage(params: { const stored = params.messageStore ? await Promise.resolve( params.messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, // sol R3 P1-1 userId: 'system', catId: 'system' as CatId, content: params.content, diff --git a/packages/api/src/domains/cats/services/session/BoundSessionHistoryImporter.ts b/packages/api/src/domains/cats/services/session/BoundSessionHistoryImporter.ts index 8205a5072a..89005d12e4 100644 --- a/packages/api/src/domains/cats/services/session/BoundSessionHistoryImporter.ts +++ b/packages/api/src/domains/cats/services/session/BoundSessionHistoryImporter.ts @@ -94,6 +94,12 @@ function mapTranscriptEventToMessage( if (!content) return null; const base: AppendMessageInput = { + provenance: { + author: evtType === 'user' ? 'user' : 'cat', + routed: false, + observation: 'derived', + sourceRef: `transcript:${session.id}:${evt.eventNo}`, + }, userId, threadId: session.threadId, catId: evtType === 'user' ? null : session.catId, diff --git a/packages/api/src/domains/cats/services/stores/factories/MessageStoreFactory.ts b/packages/api/src/domains/cats/services/stores/factories/MessageStoreFactory.ts index c6ad8d54ad..ecf13d397f 100644 --- a/packages/api/src/domains/cats/services/stores/factories/MessageStoreFactory.ts +++ b/packages/api/src/domains/cats/services/stores/factories/MessageStoreFactory.ts @@ -6,7 +6,8 @@ import type { RedisClient } from '@cat-cafe/shared/utils'; import { createModuleLogger } from '../../../../../infrastructure/logger.js'; -import { MessageStore } from '../ports/MessageStore.js'; +import { type MessageDeletionHooks, MessageStore } from '../ports/MessageStore.js'; +import type { RoutingFactProjector } from '../redis/RedisMessageStore.js'; import { RedisMessageStore } from '../redis/RedisMessageStore.js'; const log = createModuleLogger('message-store-factory'); @@ -26,14 +27,25 @@ function resolveMessageTtlSeconds(): number | undefined { export function createMessageStore( redis?: RedisClient, - options?: { onAppend?: (msg: { id: string; threadId: string; timestamp: number; content: string }) => void }, + options?: { + onAppend?: (msg: { id: string; threadId: string; timestamp: number; content: string }) => void; + /** F257 V1: async projection worker for embedded RoutingDecisionFacts (§4.5.1) — Redis mode only */ + routingFactProjection?: RoutingFactProjector; + } & MessageDeletionHooks, ): AnyMessageStore { if (redis) { const ttlSeconds = resolveMessageTtlSeconds(); return new RedisMessageStore(redis, { ...(ttlSeconds !== undefined ? { ttlSeconds } : {}), onAppend: options?.onAppend, + ...(options?.routingFactProjection ? { routingFactProjection: options.routingFactProjection } : {}), + ...(options?.onBeforeHardDelete ? { onBeforeHardDelete: options.onBeforeHardDelete } : {}), + ...(options?.onBeforeDeleteByThread ? { onBeforeDeleteByThread: options.onBeforeDeleteByThread } : {}), }); } - return new MessageStore({ onAppend: options?.onAppend }); + return new MessageStore({ + onAppend: options?.onAppend, + ...(options?.onBeforeHardDelete ? { onBeforeHardDelete: options.onBeforeHardDelete } : {}), + ...(options?.onBeforeDeleteByThread ? { onBeforeDeleteByThread: options.onBeforeDeleteByThread } : {}), + }); } diff --git a/packages/api/src/domains/cats/services/stores/ports/MessageStore.ts b/packages/api/src/domains/cats/services/stores/ports/MessageStore.ts index cbd42625d2..406a39f360 100644 --- a/packages/api/src/domains/cats/services/stores/ports/MessageStore.ts +++ b/packages/api/src/domains/cats/services/stores/ports/MessageStore.ts @@ -14,6 +14,7 @@ import type { RichMessageExtra, SchedulerMessageExtra, } from '@cat-cafe/shared'; +import type { RoutingAttemptBatch } from '../../agents/routing/routing-attempt.js'; import type { MessageMetadata } from '../../types.js'; import { isSystemUserMessage } from '../visibility.js'; // Single source of truth: ThreadStore.ts owns DEFAULT_THREAD_ID @@ -120,6 +121,16 @@ export interface StoredMessage { tracing?: { traceId: string; spanId: string; parentSpanId?: string }; systemKind?: 'a2a_routing' | 'context_briefing'; a2aRouting?: { fromCatId?: string; targetCatId?: string; invocationId?: string }; + /** + * F257 #4 (detection layer): message-signature lint result (O2→O1). Recorded + * observe-only on text-bearing agent messages at post time — `signed` = did + * the message end with a recognized `[昵称/模型🐾]` signature. Presence marks + * a linted message (denominator); absence = legacy/pre-lint. Never blocks. + * NOTE: this is the message-level detection observable, NOT the harness-ledger + * closure (auto-deviation → obj-identity-integrity), which is deferred to + * post-#3 — see cat-signature-lint.ts SCOPE note. + */ + signatureLint?: { signed: boolean }; }; /** CatIds mentioned in this message */ mentions: readonly CatId[]; @@ -144,6 +155,30 @@ export interface StoredMessage { deliveryStatus?: 'queued' | 'delivered' | 'canceled'; /** F121: ID of the message this is replying to (same thread only) */ replyTo?: string; + /** + * F257 V1: embedded RoutingDecisionFact — written in the same append as the + * message (authority record, physical co-fate per redesign §4.5.1). Semantics + * of outcomes/eligibility: T-A (§3.4). attemptId = (id, parserMode, tokenOrdinal). + */ + routingFact?: RoutingAttemptBatch; + /** + * F257 V1: persisted write-path provenance — three ORTHOGONAL axes declared + * explicitly by the writer, never inferred from nullable fields or storage + * location: + * - author: whose words these are ('user' authenticated operator 亲笔 / + * 'external_user' connector sender / 'cat' / 'system' synthesized + * surface: cards, notices, briefings, relays) + * - routed: whether a routing parser ran over this content + * (true ⇔ routingFact present — enforced at append) + * - observation: whether this row is an original behavior observation or + * a derived copy/import. Derived rows carry sourceRef and do not create a + * second T-B observation merely because storage assigned a new messageId. + * Routing cohort (§4.5.1) audits `routed`; magic-word cohort (T-B) selects + * `author==='user' && observation==='original'` regardless of routing. + * Optional here only for legacy hydration — AppendMessageInput requires it, + * so every compiler-checked writer must state all three axes. + */ + provenance?: MessageProvenance; /** ADR-008 D3: Soft delete timestamp (present = deleted) */ deletedAt?: number; /** ADR-008 D3: Who deleted this message */ @@ -152,11 +187,45 @@ export interface StoredMessage { _tombstone?: true; } +/** + * Cross-store deletion boundary. Hooks run before the message mutation so a + * failed privacy scrub aborts the delete; if the later message write fails, + * exact reconciliation can rebuild Event Memory from the still-authoritative + * message. + */ +export interface MessageDeletionHooks { + onBeforeHardDelete?: (msg: Pick) => void; + onBeforeDeleteByThread?: (threadId: string) => void; +} + +/** + * F257 V1 (sol R3 P1-1): writer-declared provenance — see StoredMessage.provenance. + * `author: 'unknown'` (sol R4 P1-2) is the EXPLICIT declaration for copy/import + * paths whose source carries no verifiable declaration (legacy messages): the + * writer states "authorship cannot be verified" instead of guessing from + * nullable fields. 'unknown' exits every exact cohort (magic-word selects + * author==='user'); it is never a substitute for a knowable author. + */ +export interface MessageProvenance { + author: 'user' | 'external_user' | 'cat' | 'system' | 'unknown'; + routed: boolean; + observation: 'original' | 'derived'; + /** Required for derived rows; forbidden for original rows. */ + sourceRef?: string; +} + +/** Runtime author-axis domain — mirrors MessageProvenance.author for JS callers. */ +export const PROVENANCE_AUTHORS = ['user', 'external_user', 'cat', 'system', 'unknown'] as const; +export const PROVENANCE_OBSERVATIONS = ['original', 'derived'] as const; + /** * Input for appending a message. threadId is optional (defaults to 'default'). + * `provenance` is REQUIRED here (unlike StoredMessage): every compiler-checked + * writer must state all three axes — a parser path cannot silently skip them. */ export type AppendMessageInput = Omit & { threadId?: string; + provenance?: MessageProvenance; /** Append may initialize only queued state; terminal delivery metadata belongs to transition methods. */ deliveryStatus?: 'queued'; /** @@ -166,6 +235,97 @@ export type AppendMessageInput = Omit { + if (!batch) { + throw new Error( + "routedProvenance requires the parser attempt batch — a parser lane cannot omit its authority record; non-parser paths must declare { provenance: { author, routed: false, observation: 'original' } } explicitly", + ); + } + return { routingFact: batch, provenance: { author, routed: true, observation: 'original' } }; +} + +/** + * Write-boundary provenance contract (sol R3 P1-1, hardened sol R4 P1-1b) — + * called by both stores on EVERY append. The declaration is runtime-required + * with a validated domain, so an uncompiled (JS) caller can neither skip it + * nor smuggle out-of-domain values that would silently fall out of every + * cohort. Violations are writer bugs and fail loudly at the write boundary + * instead of skewing cohorts later: + * provenance present, author ∈ PROVENANCE_AUTHORS, routed boolean; + * observation ∈ PROVENANCE_OBSERVATIONS; derived ⇔ non-empty sourceRef; + * author 'user' ⇔ catId null and no connector source (authenticated owner); + * author 'external_user' ⇔ catId null and connector source present; + * author 'cat' ⇔ catId set; + * ('unknown' carries no catId constraint — its meaning is precisely that + * authorship could not be verified from the source); + * routed ⇔ routingFact present (both directions). + */ +export function assertProvenanceConsistent( + msg: Pick & Pick, +): void { + const p: unknown = msg.provenance; + if (!p || typeof p !== 'object') { + throw new Error('append requires provenance: every writer must declare { author, routed, observation } explicitly'); + } + const { author, routed, observation, sourceRef } = p as { + author?: unknown; + routed?: unknown; + observation?: unknown; + sourceRef?: unknown; + }; + if (!(PROVENANCE_AUTHORS as readonly unknown[]).includes(author)) { + throw new Error(`provenance.author must be one of ${PROVENANCE_AUTHORS.join('|')}, got ${String(author)}`); + } + if (typeof routed !== 'boolean') { + throw new Error(`provenance.routed must be a boolean, got ${String(routed)}`); + } + if (!(PROVENANCE_OBSERVATIONS as readonly unknown[]).includes(observation)) { + throw new Error( + `provenance.observation must be one of ${PROVENANCE_OBSERVATIONS.join('|')}, got ${String(observation)}`, + ); + } + if (observation === 'derived' && (typeof sourceRef !== 'string' || sourceRef.trim().length === 0)) { + throw new Error('derived provenance requires a non-empty sourceRef'); + } + if (observation === 'original' && sourceRef !== undefined) { + throw new Error('original provenance must not carry sourceRef'); + } + // catId null/undefined are the same fact ("no cat attached" — Redis + // hydration folds both to null), so the author⇔catId check is loose here. + if (author === 'user' && msg.catId != null) { + throw new Error('provenance.author=user requires catId null'); + } + if (author === 'user' && msg.source !== undefined) { + throw new Error('authenticated operator provenance.author=user must not carry connector source'); + } + if (author === 'external_user' && msg.catId != null) { + throw new Error('provenance.author=external_user requires catId null'); + } + if (author === 'external_user' && msg.source === undefined) { + throw new Error('provenance.author=external_user requires connector source'); + } + if (author === 'cat' && !msg.catId) { + throw new Error('provenance.author=cat requires a catId'); + } + if (routed && !msg.routingFact) { + throw new Error('provenance.routed requires a routingFact (parser authority record)'); + } + if (!routed && msg.routingFact) { + throw new Error('routingFact requires provenance.routed (facts only come from parser lanes)'); + } +} + /** * Enforce delivery lifecycle ownership for JavaScript callers that can bypass * the structural AppendMessageInput boundary. @@ -180,6 +340,20 @@ export function assertValidAppendDeliveryMetadata(msg: AppendMessageInput): void } } +/** + * Single authenticated-operator truth used by T-B/T-C. Human-shaped nullable + * fields are insufficient: connector senders and derived branch copies also + * carry catId=null. Only a fresh local owner declaration is an operator act. + */ +export function isAuthenticatedOperatorMessage(msg: Pick): boolean { + return ( + msg.provenance?.author === 'user' && + msg.provenance.observation === 'original' && + msg.catId === null && + msg.source === undefined + ); +} + /** * Stream-only metadata collected by route-serial after a callback message was * already persisted. It may augment the callback bubble, but must not replace @@ -279,6 +453,8 @@ export interface IMessageStore { append(msg: AppendMessageInput): StoredMessage | Promise; /** Get a single message by its ID. Returns null if not found. */ getById(id: string): StoredMessage | null | Promise; + /** Get multiple messages by ID in a single round. Missing IDs are omitted. */ + getByIds(ids: readonly string[]): StoredMessage[] | Promise; getRecent(limit?: number, userId?: string): StoredMessage[] | Promise; getMentionsFor( catId: CatId, @@ -402,13 +578,17 @@ export class MessageStore { private readonly contentDedupIndex = new Map(); /** F102 KD-34: Listener called after every successful append (fire-and-forget) */ onAppend?: (msg: Pick) => void; + private readonly deletionHooks: MessageDeletionHooks; - constructor(options?: { - maxMessages?: number; - onAppend?: (msg: Pick) => void; - }) { + constructor( + options?: { + maxMessages?: number; + onAppend?: (msg: Pick) => void; + } & MessageDeletionHooks, + ) { this.maxMessages = options?.maxMessages ?? MAX_MESSAGES; this.onAppend = options?.onAppend; + this.deletionHooks = options ?? {}; } private buildIdempotencyIndexKey(userId: string, threadId: string, idempotencyKey?: string): string | null { @@ -430,6 +610,7 @@ export class MessageStore { * Append a message to the store. Returns the stored message with generated id. */ append(msg: AppendMessageInput): StoredMessage { + assertProvenanceConsistent(msg); // sol R3 P1-1: writer bugs fail at the write boundary assertValidAppendDeliveryMetadata(msg); assertValidStoredMessageTimestamp(msg.timestamp); const threadId = msg.threadId ?? DEFAULT_THREAD_ID; @@ -452,6 +633,8 @@ export class MessageStore { id: generateSortableId(msg.timestamp), threadId, }; + // F257 V1 (sol R1 P1-1): zero-token batches persist too — the fact field is + // the producer-run marker the coverage cohort audits (parity with RedisMessageStore). this.messages.push(stored); if (idempotencyIndexKey) { this.idempotencyIndex.set(idempotencyIndexKey, stored.id); @@ -484,6 +667,18 @@ export class MessageStore { return this.messages.find((m) => m.id === id) ?? null; } + /** + * Get multiple messages by ID. Missing IDs are omitted. + */ + getByIds(ids: readonly string[]): StoredMessage[] { + const found: StoredMessage[] = []; + for (const id of ids) { + const msg = this.getById(id); + if (msg) found.push(msg); + } + return found; + } + /** * Get the most recent N messages. * When userId is provided, only returns messages from that user's session. @@ -687,6 +882,8 @@ export class MessageStore { */ deleteByThread(threadId: string): number { const removed = this.messages.filter((m) => m.threadId === threadId); + this.deletionHooks.onBeforeDeleteByThread?.(threadId); + if (removed.length === 0) return 0; const before = this.messages.length; this.messages = this.messages.filter((m) => m.threadId !== threadId); this.pruneIdempotencyIndexForMessageIds(removed.map((entry) => entry.id)); @@ -699,7 +896,7 @@ export class MessageStore { */ softDelete(id: string, deletedBy: string): StoredMessage | null { const msg = this.messages.find((m) => m.id === id); - if (!msg) return null; + if (!msg || msg._tombstone) return null; msg.deletedAt = Date.now(); msg.deletedBy = deletedBy; return msg; @@ -711,7 +908,8 @@ export class MessageStore { */ hardDelete(id: string, deletedBy: string): StoredMessage | null { const msg = this.messages.find((m) => m.id === id); - if (!msg) return null; + if (!msg || msg._tombstone) return null; + this.deletionHooks.onBeforeHardDelete?.(msg); msg.content = ''; msg.mentions = []; delete msg.contentBlocks; @@ -719,6 +917,8 @@ export class MessageStore { delete msg.metadata; delete msg.extra; delete msg.thinking; + delete msg.routingFact; + delete msg.provenance; msg.deletedAt = Date.now(); msg.deletedBy = deletedBy; msg._tombstone = true; @@ -747,6 +947,7 @@ export class MessageStore { for (const msg of this.messages) { if (msg.threadId !== threadId) continue; if (msg.userId !== userId) continue; + if (msg._tombstone) continue; if (msg.visibility === 'whisper' && !msg.revealedAt) { msg.revealedAt = now; count++; @@ -760,14 +961,14 @@ export class MessageStore { */ updateExtra(id: string, extra: NonNullable): StoredMessage | null { const msg = this.messages.find((m) => m.id === id); - if (!msg) return null; + if (!msg || msg._tombstone) return null; msg.extra = extra; return msg; } augmentStreamMetadata(id: string, patch: StreamMetadataAugmentInput): StoredMessage | null { const msg = this.messages.find((m) => m.id === id); - if (!msg) return null; + if (!msg || msg._tombstone) return null; return applyStreamMetadataAugment(msg, patch); } @@ -777,7 +978,7 @@ export class MessageStore { markDelivered(id: string, deliveredAt: number): StoredMessage | null { assertValidStoredMessageTimestamp(deliveredAt); const msg = this.messages.find((m) => m.id === id); - if (!msg) return null; + if (!msg || msg._tombstone) return null; if (!isQueuedForDeliveryTransition(msg)) return null; // CAS no-op: not queued msg.deliveredAt = deliveredAt; msg.deliveryStatus = 'delivered'; @@ -791,7 +992,7 @@ export class MessageStore { */ markCanceled(id: string): StoredMessage | null { const msg = this.messages.find((m) => m.id === id); - if (!msg) return null; + if (!msg || msg._tombstone) return null; if (!isQueuedForDeliveryTransition(msg)) return null; // CAS no-op: not queued msg.deliveryStatus = 'canceled'; return msg; diff --git a/packages/api/src/domains/cats/services/stores/redis-keys/routing-fact-keys.ts b/packages/api/src/domains/cats/services/stores/redis-keys/routing-fact-keys.ts new file mode 100644 index 0000000000..d21b66cbd3 --- /dev/null +++ b/packages/api/src/domains/cats/services/stores/redis-keys/routing-fact-keys.ts @@ -0,0 +1,22 @@ +/** + * F257 V1 — Redis key patterns for the RoutingDecisionFact query projection. + * + * The authority record is the `routingFact` field embedded in the message hash + * (written in the same append — §4.5.1). These keys are the asynchronously + * derived query-side projection; they are rebuildable from the authority + * records at any time and carry no truth of their own. + * + * All keys share the cat-cafe: prefix set by the Redis client. TTL=0 + * (persistent) — evaluation needs a ≥14d baseline window. + */ + +export const RoutingFactKeys = { + /** Owner-scoped time index of fact-carrying messages: ZSET score=timestamp member=messageId */ + index: (ownerUserId: string) => `routing-fact:idx:${ownerUserId}`, + + /** §4.5.1①: owner-scoped high-watermark — highest projected authority id (sortable messageId) */ + watermark: (ownerUserId: string) => `routing-fact:watermark:${ownerUserId}`, + + /** §4.5.1③: projection worker errors — ZSET score=errorTs member=messageId (visible, never swallowed) */ + projectionErrors: (ownerUserId: string) => `routing-fact:proj-errors:${ownerUserId}`, +} as const; diff --git a/packages/api/src/domains/cats/services/stores/redis/RedisMessageStore.ts b/packages/api/src/domains/cats/services/stores/redis/RedisMessageStore.ts index 6ba3797e66..912c26c8ed 100644 --- a/packages/api/src/domains/cats/services/stores/redis/RedisMessageStore.ts +++ b/packages/api/src/domains/cats/services/stores/redis/RedisMessageStore.ts @@ -15,9 +15,15 @@ import type { CatId } from '@cat-cafe/shared'; import type { RedisClient } from '@cat-cafe/shared/utils'; import { createModuleLogger } from '../../../../../infrastructure/logger.js'; -import type { AppendMessageInput, StoredMessage, StreamMetadataAugmentInput } from '../ports/MessageStore.js'; +import type { + AppendMessageInput, + MessageDeletionHooks, + StoredMessage, + StreamMetadataAugmentInput, +} from '../ports/MessageStore.js'; import { applyStreamMetadataAugment, + assertProvenanceConsistent, assertValidAppendDeliveryMetadata, assertValidStoredMessageTimestamp, DEFAULT_THREAD_ID, @@ -26,13 +32,15 @@ import { } from '../ports/MessageStore.js'; import { MessageKeys } from '../redis-keys/message-keys.js'; import { isSystemUserMessage } from '../visibility.js'; -import { APPEND_LUA, CANCEL_LUA, DELIVER_LUA, REASSIGN_LUA } from './redis-message-delivery-lua-scripts.js'; +import { CANCEL_LUA, DELIVER_LUA, REASSIGN_LUA } from './redis-message-delivery-lua-scripts.js'; import { + hydrateProvenance, safeParseConnectorSource, safeParseContentBlocks, safeParseExtra, safeParseMentions, safeParseMetadata, + safeParseRoutingFact, safeParseToolEvents, serializeExtra, } from './redis-message-parsers.js'; @@ -42,6 +50,65 @@ const log = createModuleLogger('redis-message-store'); const DEFAULT_LIMIT = 50; const DEFAULT_TTL_SECONDS = 0; // persistent — set >0 via env to enable expiry +const HARD_DELETE_MESSAGE_LUA = ` +if redis.call('EXISTS', KEYS[1]) == 0 then + return 0 +end +local authorityOwner = redis.call('HGET', KEYS[1], 'userId') or '' +if redis.call('HGET', KEYS[1], '_tombstone') == '1' then + return {2, authorityOwner} +end +redis.call('HSET', KEYS[1], + 'content', '', + 'contentBlocks', '', + 'toolEvents', '', + 'metadata', '', + 'extra', '', + 'thinking', '', + 'mentions', '[]', + 'deletedAt', ARGV[1], + 'deletedBy', ARGV[2], + '_tombstone', '1') +redis.call('HDEL', KEYS[1], 'routingFact', 'provenance') +return {1, authorityOwner} +`; + +const MUTATE_LIVE_OR_SOFT_DELETED_MESSAGE_LUA = ` +if redis.call('EXISTS', KEYS[1]) == 0 then + return 0 +end +if redis.call('HGET', KEYS[1], '_tombstone') == '1' then + return 0 +end +if #ARGV > 0 then + redis.call('HSET', KEYS[1], unpack(ARGV)) +end +return 1 +`; + +const RESTORE_SOFT_DELETED_LUA = ` +if redis.call('EXISTS', KEYS[1]) == 0 then + return 0 +end +if redis.call('HGET', KEYS[1], '_tombstone') == '1' then + return 0 +end +if not redis.call('HGET', KEYS[1], 'deletedAt') or not redis.call('HGET', KEYS[1], 'deletedBy') then + return 0 +end +redis.call('HDEL', KEYS[1], 'deletedAt', 'deletedBy') +return 1 +`; + +/** + * F257 V1 (§4.5.1): async projection worker for embedded RoutingDecisionFacts. + * Contract: project() never rejects — it records its own failures persistently + * (no silent swallow; reconcile-before-evaluate repairs any gap). + */ +export interface RoutingFactProjector { + project(msg: Pick): Promise; +} + const REDIS_NUMBER_ALIASES = new Map([ ['', Number.NaN], ['inf', Number.POSITIVE_INFINITY], @@ -64,16 +131,24 @@ export class RedisMessageStore { private readonly ttlSeconds: number | null; /** F102 KD-34: Listener called after every successful append (fire-and-forget) */ onAppend?: (msg: Pick) => void; + /** F257 V1: routing-fact projection worker (owns its error accounting — §4.5.1③) */ + private readonly routingFactProjection?: RoutingFactProjector; + private readonly deletionHooks: MessageDeletionHooks; constructor( redis: RedisClient, options?: { ttlSeconds?: number; onAppend?: (msg: Pick) => void; - }, + routingFactProjection?: RoutingFactProjector; + } & MessageDeletionHooks, ) { this.redis = redis; this.onAppend = options?.onAppend; + if (options?.routingFactProjection) { + this.routingFactProjection = options.routingFactProjection; + } + this.deletionHooks = options ?? {}; const raw = options?.ttlSeconds ?? DEFAULT_TTL_SECONDS; if (!Number.isFinite(raw) || raw <= 0) { this.ttlSeconds = null; @@ -93,16 +168,55 @@ export class RedisMessageStore { return p && rawKey.startsWith(p) ? rawKey.slice(p.length) : rawKey; } + private async scanKeys(pattern: string): Promise { + const matchPattern = `${this.keyPrefix}${pattern}`; + const matched: string[] = []; + let cursor = '0'; + do { + const [nextCursor, keys] = await this.redis.scan(cursor, 'MATCH', matchPattern, 'COUNT', 200); + cursor = nextCursor; + matched.push(...keys.map((key) => this.stripPrefix(key))); + } while (cursor !== '0'); + return matched; + } + + private async scanAuthorityIdsByThread(threadId: string): Promise { + const keys = await this.scanKeys(MessageKeys.detail('*')); + if (keys.length === 0) return []; + const pipeline = this.redis.pipeline(); + for (const key of keys) pipeline.hget(key, 'threadId'); + const results = await pipeline.exec(); + const ids: string[] = []; + for (let index = 0; index < keys.length; index += 1) { + const [err, value] = results?.[index] ?? [null, null]; + const key = keys[index]; + if (!err && value === threadId && key) ids.push(key.replace(/^msg:/, '')); + } + return ids; + } + + /** + * v2.3.8 terminal write barrier for every single-hash mutator. The existence + * and tombstone checks live in the same Lua command as HSET, so a caller that + * read an active snapshot before hard/physical deletion cannot recreate data. + */ + private async mutateLiveOrSoftDeletedMessage(id: string, fields: Record): Promise { + const args = Object.entries(fields).flat(); + return ( + Number(await this.redis.eval(MUTATE_LIVE_OR_SOFT_DELETED_MESSAGE_LUA, 1, MessageKeys.detail(id), ...args)) === 1 + ); + } + async append(msg: AppendMessageInput): Promise { + assertProvenanceConsistent(msg); // sol R3 P1-1: writer bugs fail at the write boundary assertValidAppendDeliveryMetadata(msg); assertValidStoredMessageTimestamp(msg.timestamp); const threadId = msg.threadId ?? DEFAULT_THREAD_ID; + const id = generateSortableId(msg.timestamp); const idempotencyIndexKey = msg.idempotencyKey ? MessageKeys.idempotency(msg.userId, threadId, msg.idempotencyKey) : null; - // Keep the common replay path ahead of ID generation; the Lua check below - // remains the authoritative claim for concurrent callers. if (idempotencyIndexKey) { const existingId = await this.redis.get(idempotencyIndexKey); if (existingId) { @@ -110,90 +224,128 @@ export class RedisMessageStore { if (existingMessage) { return existingMessage; } + await this.redis.del(idempotencyIndexKey); + } + + const claimed = + this.ttlSeconds === null + ? await this.redis.set(idempotencyIndexKey, id, 'NX') + : await this.redis.set(idempotencyIndexKey, id, 'EX', this.ttlSeconds, 'NX'); + + if (claimed !== 'OK') { + const claimedId = await this.redis.get(idempotencyIndexKey); + if (claimedId) { + const existingMessage = await this.getById(claimedId); + if (existingMessage) { + return existingMessage; + } + } + throw new Error('message idempotency key contention'); } - // Stale reference: do NOT delete here (avoids a check-then-act race). - // APPEND_LUA will reclaim it atomically. } - const id = generateSortableId(msg.timestamp); const { idempotencyKey, ...payload } = msg; void idempotencyKey; const stored: StoredMessage = { ...payload, id, threadId }; + const score = msg.timestamp; + + const hashKey = MessageKeys.detail(id); + const pipeline = this.redis.multi(); - const hashFields: Record = { + // Store message hash (including threadId, contentBlocks, toolEvents, metadata) + pipeline.hset(hashKey, { id, threadId, userId: msg.userId, catId: msg.catId ?? '', content: msg.content, + contentBlocks: msg.contentBlocks ? JSON.stringify(msg.contentBlocks) : '', + toolEvents: msg.toolEvents ? JSON.stringify(msg.toolEvents) : '', + metadata: msg.metadata ? JSON.stringify(msg.metadata) : '', + extra: msg.extra ? serializeExtra(msg.extra) : '', mentions: JSON.stringify(msg.mentions), timestamp: String(msg.timestamp), - }; - - if (msg.contentBlocks !== undefined) { - hashFields.contentBlocks = JSON.stringify(msg.contentBlocks); - } - if (msg.toolEvents !== undefined) { - hashFields.toolEvents = JSON.stringify(msg.toolEvents); - } - if (msg.metadata) { - hashFields.metadata = JSON.stringify(msg.metadata); - } - if (msg.extra) { - hashFields.extra = serializeExtra(msg.extra); - } - if (msg.thinking) { - hashFields.thinking = msg.thinking; - } - if (msg.origin) { - hashFields.origin = msg.origin; - } - if (msg.visibility) { - hashFields.visibility = msg.visibility; - } - if (msg.whisperTo !== undefined) { - hashFields.whisperTo = JSON.stringify(msg.whisperTo); - } - if (msg.source) { - hashFields.source = JSON.stringify(msg.source); - } - if (msg.mentionsUser) { - hashFields.mentionsUser = '1'; + ...(msg.thinking ? { thinking: msg.thinking } : {}), + ...(msg.origin ? { origin: msg.origin } : {}), + ...(msg.visibility ? { visibility: msg.visibility } : {}), + ...(msg.whisperTo ? { whisperTo: JSON.stringify(msg.whisperTo) } : {}), + ...(msg.source ? { source: JSON.stringify(msg.source) } : {}), + ...(msg.mentionsUser ? { mentionsUser: '1' } : {}), + ...(msg.deliveryStatus ? { deliveryStatus: msg.deliveryStatus } : {}), + ...(msg.replyTo ? { replyTo: msg.replyTo } : {}), + // F257 V1 §4.5.1: authority write — same hset as the message (physical co-fate). + // sol R1 P1-1: zero-token batches persist too — the fact field doubles as the + // producer-run marker the coverage cohort audits. + ...(msg.routingFact ? { routingFact: JSON.stringify(msg.routingFact) } : {}), + ...(msg.provenance ? { provenance: JSON.stringify(msg.provenance) } : {}), + }); + if (this.ttlSeconds !== null) { + pipeline.expire(hashKey, this.ttlSeconds); } - if (msg.deliveryStatus) { - hashFields.deliveryStatus = msg.deliveryStatus; + + // Add to global timeline + pipeline.zadd(MessageKeys.TIMELINE, String(score), id); + + // Add to user timeline + pipeline.zadd(MessageKeys.user(msg.userId), String(score), id); + + // Add to thread timeline + pipeline.zadd(MessageKeys.thread(threadId), String(score), id); + + // Add to per-cat mention sets + for (const catId of msg.mentions) { + pipeline.zadd(MessageKeys.mentions(catId), String(score), id); } - if (msg.replyTo) { - hashFields.replyTo = msg.replyTo; + + if (this.ttlSeconds !== null) { + // Prune expired entries from sorted sets (score < now - TTL). + const cutoff = String(Date.now() - this.ttlSeconds * 1000); + pipeline.zremrangebyscore(MessageKeys.TIMELINE, '-inf', cutoff); + pipeline.zremrangebyscore(MessageKeys.user(msg.userId), '-inf', cutoff); + pipeline.zremrangebyscore(MessageKeys.thread(threadId), '-inf', cutoff); + for (const catId of msg.mentions) { + pipeline.zremrangebyscore(MessageKeys.mentions(catId), '-inf', cutoff); + } + + // Set EXPIRE on index zsets so "silent" keys eventually disappear + pipeline.expire(MessageKeys.TIMELINE, this.ttlSeconds); + pipeline.expire(MessageKeys.user(msg.userId), this.ttlSeconds); + pipeline.expire(MessageKeys.thread(threadId), this.ttlSeconds); + if (idempotencyIndexKey) { + pipeline.expire(idempotencyIndexKey, this.ttlSeconds); + } + for (const catId of msg.mentions) { + pipeline.expire(MessageKeys.mentions(catId), this.ttlSeconds); + } } - const returnedId = (await this.redis.eval( - APPEND_LUA, - 1, - MessageKeys.detail(id), - id, - JSON.stringify(hashFields), - JSON.stringify(msg.mentions), - String(msg.timestamp), - idempotencyIndexKey ?? '', - this.keyPrefix, - String(this.ttlSeconds ?? 0), - )) as string; - - // If a concurrent caller with the same idempotency key won, return the - // message they created without firing our onAppend. - if (returnedId !== id) { - const existingMessage = await this.getById(returnedId); - if (existingMessage) { - return existingMessage; + try { + const results = await pipeline.exec(); + // sol R2 P1-3: MULTI has no rollback and resolves per-command errors in + // the result tuples — swallowing them reports a successful append whose + // authority never reached the owner timeline (or vice versa). + if (!results) throw new Error('message append: pipeline exec aborted (null result)'); + for (const [err] of results) { + if (err) throw err; } - // The concurrent winner's hash vanished (deleteByThread / TTL) between the - // Lua claim and this hydration. Do not fall through to the created path, - // which would fire onAppend for a message that was never persisted. - throw new Error(`Idempotency winner ${returnedId} for key ${idempotencyKey} vanished before hydration`); + } catch (error) { + // Partial-execution cleanup: MULTI may have landed a subset of the + // writes. Undo best-effort so the message is either fully visible or + // not visible at all — a hash-less timeline entry or an orphan hash + // both corrupt the collection-integrity audits (§4.5.1 / T-B). + await this.undoAppendArtifacts(id, msg.userId, threadId, msg.mentions, idempotencyIndexKey); + throw error; + } + + // F257 V1 (§4.5.1): async projection derive for the embedded fact. project() + // owns its error accounting (logged + persisted error marker — never a silent + // swallow); reconcile-before-evaluate repairs any missed entry. + if (stored.routingFact && this.routingFactProjection) { + void this.routingFactProjection.project(stored); } // F102 KD-34: fire-and-forget append listener for thread index updates + // P2 fix: wrap in try-catch to handle sync throws (Promise.resolve only catches async rejections) if (this.onAppend) { try { void Promise.resolve(this.onAppend(stored)).catch(() => {}); @@ -205,11 +357,51 @@ export class RedisMessageStore { return stored; } + /** + * Best-effort undo of a partially executed append (sol R2 P1-3). Removes the + * message hash, every index entry this append targeted and the idempotency + * claim, so a failed append leaves neither an orphan hash nor a hash-less + * timeline entry. Its own failures are logged loudly — the original append + * error is what propagates. + */ + private async undoAppendArtifacts( + id: string, + userId: string, + threadId: string, + mentions: readonly string[], + idempotencyIndexKey: string | null, + ): Promise { + try { + const undo = this.redis.pipeline(); + undo.del(MessageKeys.detail(id)); + undo.zrem(MessageKeys.TIMELINE, id); + undo.zrem(MessageKeys.user(userId), id); + undo.zrem(MessageKeys.thread(threadId), id); + for (const catId of mentions) { + undo.zrem(MessageKeys.mentions(catId), id); + } + await undo.exec(); + if (idempotencyIndexKey) { + const existingId = await this.redis.get(idempotencyIndexKey); + if (existingId === id) { + await this.redis.del(idempotencyIndexKey); + } + } + } catch (undoError) { + log.error({ undoError, messageId: id, threadId }, 'message append undo failed — partial artifacts may remain'); + } + } + async getById(id: string): Promise { const data = await this.redis.hgetall(MessageKeys.detail(id)); return this.hydrateHash(data); } + async getByIds(ids: readonly string[]): Promise { + const results = await Promise.all(ids.map((id) => this.getById(id))); + return results.filter((m): m is StoredMessage => m !== null); + } + /** * Convert a Redis hash (Record from HGETALL) into a StoredMessage. * Shared by getById (direct HGETALL) and parseLuaHgetall (Lua-returned HGETALL). @@ -222,6 +414,8 @@ export class RedisMessageStore { const parsedMetadata = safeParseMetadata(data.metadata); const parsedExtra = safeParseExtra(data.extra); const parsedSource = safeParseConnectorSource(data.source); + const parsedRoutingFact = safeParseRoutingFact(data.routingFact); + const parsedProvenance = hydrateProvenance(data.provenance); const deletedAt = data.deletedAt ? parseInt(data.deletedAt, 10) : undefined; return { id: data.id, @@ -249,6 +443,8 @@ export class RedisMessageStore { ...(parsedSource ? { source: parsedSource } : {}), ...(data.mentionsUser === '1' ? { mentionsUser: true } : {}), ...(data.replyTo ? { replyTo: data.replyTo } : {}), + ...(parsedRoutingFact ? { routingFact: parsedRoutingFact } : {}), + ...(parsedProvenance ? { provenance: parsedProvenance } : {}), }; } @@ -740,25 +936,71 @@ export class RedisMessageStore { */ async deleteByThread(threadId: string): Promise { const key = MessageKeys.thread(threadId); + // The privacy fence is the deletion linearization point. It must run even + // when the thread index is empty or contains orphan members with no hash. + this.deletionHooks.onBeforeDeleteByThread?.(threadId); + + const [threadMembers, authorityIds] = await Promise.all([ + this.redis.zrange(key, 0, -1), + this.scanAuthorityIdsByThread(threadId), + ]); + const ids = [...new Set([...threadMembers, ...authorityIds])]; + const idSet = new Set(ids); + const idempotencyKeys = await this.scanKeys('msg:idem:*'); + + const idempotencyRead = this.redis.pipeline(); + for (const idempotencyKey of idempotencyKeys) idempotencyRead.get(idempotencyKey); + const idempotencyResults = await idempotencyRead.exec(); + const matchingIdempotencyKeys: string[] = []; + for (let index = 0; index < idempotencyKeys.length; index += 1) { + const [err, value] = idempotencyResults?.[index] ?? [null, null]; + const idempotencyKey = idempotencyKeys[index]; + if (!err && typeof value === 'string' && idSet.has(value) && idempotencyKey) { + matchingIdempotencyKeys.push(idempotencyKey); + } + } - // Get all message IDs in this thread - const ids = await this.redis.zrange(key, 0, -1); - if (ids.length === 0) return 0; - - const pipeline = this.redis.multi(); - - // Delete each message hash + // Delete authority first, but retain the thread index until every derived + // sibling is clean. Once detail hashes are absent, guarded message/projector + // writers cannot create a new sibling between the final SCAN and cleanup. + const transition = this.redis.multi(); for (const id of ids) { - pipeline.del(MessageKeys.detail(id)); + // Authority scan may discover an id missing from the thread index. Add a + // temporary discovery anchor in the same transaction that deletes the + // hash; NX preserves healthy timeline scores. A cleanup failure can then + // retry from the thread id even though authority is already absent. + transition.zadd(key, 'NX', '0', id); + transition.del(MessageKeys.detail(id)); + transition.zrem(MessageKeys.TIMELINE, id); } + for (const idempotencyKey of matchingIdempotencyKeys) transition.del(idempotencyKey); - // Delete the thread sorted set - pipeline.del(key); - - // Note: We don't clean up global timeline, user timeline, or mention sets - // as those will auto-expire via TTL. Cleaning them would be O(n) expensive. + const transitionResults = await transition.exec(); + if (!transitionResults) throw new Error('message thread delete: authority transition aborted'); + for (const [err] of transitionResults) { + if (err) throw err; + } - await pipeline.exec(); + // A missing/malformed detail hash cannot identify its historic owner or + // mention indexes. Scan after the authority transition so the key set is + // stable: any stale writer now fails its atomic terminal check. Keeping the + // thread members until this phase succeeds makes cleanup retryable. + const indexKeys = await Promise.all([ + this.scanKeys('msg:user:*'), + this.scanKeys('msg:mentions:*'), + this.scanKeys('routing-fact:idx:*'), + this.scanKeys('routing-fact:proj-errors:*'), + ]).then((groups) => groups.flat()); + const cleanup = this.redis.multi(); + for (const id of ids) { + for (const indexKey of indexKeys) cleanup.zrem(indexKey, id); + } + const cleanupResults = await cleanup.exec(); + if (!cleanupResults) throw new Error('message thread delete: sibling cleanup aborted'); + for (const [err] of cleanupResults) { + if (err) throw err; + } + await this.redis.del(key); return ids.length; } @@ -769,10 +1011,11 @@ export class RedisMessageStore { const msg = await this.getById(id); if (!msg) return null; const now = Date.now(); - await this.redis.hset(MessageKeys.detail(id), { + const mutated = await this.mutateLiveOrSoftDeletedMessage(id, { deletedAt: String(now), deletedBy, }); + if (!mutated) return null; msg.deletedAt = now; msg.deletedBy = deletedBy; return msg; @@ -784,19 +1027,33 @@ export class RedisMessageStore { async hardDelete(id: string, deletedBy: string): Promise { const msg = await this.getById(id); if (!msg) return null; + if (!msg._tombstone) this.deletionHooks.onBeforeHardDelete?.(msg); const now = Date.now(); - await this.redis.hset(MessageKeys.detail(id), { - content: '', - contentBlocks: '', - toolEvents: '', - metadata: '', - extra: '', - thinking: '', - mentions: '[]', - deletedAt: String(now), + const transition = await this.redis.eval( + HARD_DELETE_MESSAGE_LUA, + 1, + MessageKeys.detail(id), + String(now), deletedBy, - _tombstone: '1', - }); + ); + if (!Array.isArray(transition)) return null; + const transitionStatus = Number(transition[0]); + if (transitionStatus !== 1 && transitionStatus !== 2) return null; + const routingIndexKeys = await Promise.all([ + this.scanKeys('routing-fact:idx:*'), + this.scanKeys('routing-fact:proj-errors:*'), + ]).then((groups) => groups.flat()); + const pipeline = this.redis.multi(); + for (const indexKey of routingIndexKeys) pipeline.zrem(indexKey, id); + for (const catId of msg.mentions) { + pipeline.zrem(MessageKeys.mentions(catId), id); + } + const results = await pipeline.exec(); + if (!results) throw new Error('message hard delete: pipeline exec aborted'); + for (const [err] of results) { + if (err) throw err; + } + if (transitionStatus === 2) return null; msg.content = ''; msg.mentions = []; delete msg.contentBlocks; @@ -804,6 +1061,8 @@ export class RedisMessageStore { delete msg.metadata; delete msg.extra; delete msg.thinking; + delete msg.routingFact; + delete msg.provenance; msg.deletedAt = now; msg.deletedBy = deletedBy; msg._tombstone = true; @@ -817,7 +1076,8 @@ export class RedisMessageStore { async restore(id: string): Promise { const msg = await this.getById(id); if (!msg || !msg.deletedAt || msg._tombstone) return null; - await this.redis.hdel(MessageKeys.detail(id), 'deletedAt', 'deletedBy'); + const restored = Number(await this.redis.eval(RESTORE_SOFT_DELETED_LUA, 1, MessageKeys.detail(id))); + if (restored !== 1) return null; delete msg.deletedAt; delete msg.deletedBy; return msg; @@ -838,8 +1098,7 @@ export class RedisMessageStore { if (fields[0] !== 'whisper') continue; if (fields[1]) continue; // already revealed if (fields[2] !== userId) continue; // only reveal caller's whispers - await this.redis.hset(MessageKeys.detail(id), 'revealedAt', now); - count++; + if (await this.mutateLiveOrSoftDeletedMessage(id, { revealedAt: now })) count++; } return count; } @@ -849,7 +1108,7 @@ export class RedisMessageStore { const msg = await this.getById(id); if (!msg) return null; const merged = { ...msg.extra, ...extra }; - await this.redis.hset(MessageKeys.detail(id), { extra: serializeExtra(merged) }); + if (!(await this.mutateLiveOrSoftDeletedMessage(id, { extra: serializeExtra(merged) }))) return null; msg.extra = merged; return msg; } @@ -865,9 +1124,7 @@ export class RedisMessageStore { if (patch.replyTo && augmented.replyTo) fields.replyTo = augmented.replyTo; if (patch.mentionsUser && augmented.mentionsUser) fields.mentionsUser = '1'; if (patch.extra && augmented.extra) fields.extra = serializeExtra(augmented.extra); - if (Object.keys(fields).length > 0) { - await this.redis.hset(MessageKeys.detail(id), fields); - } + if (!(await this.mutateLiveOrSoftDeletedMessage(id, fields))) return null; return augmented; } @@ -971,6 +1228,8 @@ export class RedisMessageStore { const parsedMetadata = safeParseMetadata(d.metadata); const parsedExtra = safeParseExtra(d.extra); const parsedSource = safeParseConnectorSource(d.source); + const parsedRoutingFact = safeParseRoutingFact(d.routingFact); + const parsedProvenanceD = hydrateProvenance(d.provenance); messages.push({ id: d.id, threadId: d.threadId || DEFAULT_THREAD_ID, @@ -997,6 +1256,8 @@ export class RedisMessageStore { ...(parsedSource ? { source: parsedSource } : {}), ...(d.mentionsUser === '1' ? { mentionsUser: true } : {}), ...(d.replyTo ? { replyTo: d.replyTo } : {}), + ...(parsedRoutingFact ? { routingFact: parsedRoutingFact } : {}), + ...(parsedProvenanceD ? { provenance: parsedProvenanceD } : {}), }); } return messages; diff --git a/packages/api/src/domains/cats/services/stores/redis/RedisRoutingFactProjection.ts b/packages/api/src/domains/cats/services/stores/redis/RedisRoutingFactProjection.ts new file mode 100644 index 0000000000..6c010297f3 --- /dev/null +++ b/packages/api/src/domains/cats/services/stores/redis/RedisRoutingFactProjection.ts @@ -0,0 +1,600 @@ +/** + * F257 V1 — RoutingDecisionFact query projection (§4.5.1 of the F257 redesign). + * + * Authority = the `routingFact` field embedded in the message hash (same-append + * co-fate; RedisMessageStore). This module derives the owner-scoped query + * projection and implements the §4.5.1 collection-integrity contract: + * ① persisted owner-scoped high-watermark (highest projected authority id) + * ② reconcile-before-evaluate: authority vs projection window对账 with + * synchronous idempotent rebuild; rebuild failure → metrics unmeasurable + * ③ projection worker errors are never silently swallowed — they are logged + * AND persisted to an error ZSET (collection-health visibility) + * + * Metric semantics (@解析成功率 per parserMode): T-A (§3.4) via the mapping + * functions in routing-attempt.ts — not restated here. + * + * Redis-only by design: the in-memory MessageStore has no projection; metric + * endpoints report unmeasurable without Redis. + */ + +import type { RedisClient } from '@cat-cafe/shared/utils'; +import { createModuleLogger } from '../../../../../infrastructure/logger.js'; +import { + isMetricEligibleOutcome, + isSuccessOutcome, + type RoutingParserMode, +} from '../../agents/routing/routing-attempt.js'; +import type { StoredMessage } from '../ports/MessageStore.js'; +import { MessageKeys } from '../redis-keys/message-keys.js'; +import { RoutingFactKeys } from '../redis-keys/routing-fact-keys.js'; +import { + type PersistedMessageInvalidReason, + parsePersistedMessageRecord, + safeParseRoutingFact, +} from './redis-message-parsers.js'; + +const log = createModuleLogger('routing-fact-projection'); + +/** + * v2.3.8: the projection commit and authority-state check are one Redis + * linearization point. A stale append snapshot may only project while the + * message is still active and its owner/fact still match that snapshot. + */ +const PROJECT_ACTIVE_ROUTING_FACT_LUA = ` +local function drop_stale() + redis.call('ZREM', KEYS[2], ARGV[1]) + redis.call('ZREM', KEYS[4], ARGV[1]) + return 0 +end +if redis.call('EXISTS', KEYS[1]) == 0 then + return drop_stale() +end +if redis.call('HGET', KEYS[1], '_tombstone') == '1' or redis.call('HGET', KEYS[1], 'deletedAt') then + return drop_stale() +end +if redis.call('HGET', KEYS[1], 'id') ~= ARGV[1] + or redis.call('HGET', KEYS[1], 'userId') ~= ARGV[2] + or redis.call('HGET', KEYS[1], 'routingFact') ~= ARGV[3] then + return drop_stale() +end +local effectiveOrderAt = redis.call('HGET', KEYS[1], 'deliveredAt') + or redis.call('HGET', KEYS[1], 'timestamp') +if not effectiveOrderAt then + return drop_stale() +end +redis.call('ZADD', KEYS[2], effectiveOrderAt, ARGV[1]) +redis.call('ZREM', KEYS[4], ARGV[1]) +local cur = redis.call('GET', KEYS[3]) +if (not cur) or (ARGV[1] > cur) then + redis.call('SET', KEYS[3], ARGV[1]) +end +return 1 +`; + +/** Error visibility is also a projection write and must obey the same terminal fence. */ +const RECORD_ACTIVE_PROJECTION_ERROR_LUA = ` +local function drop_stale() + redis.call('ZREM', KEYS[2], ARGV[1]) + return 0 +end +if redis.call('EXISTS', KEYS[1]) == 0 then + return drop_stale() +end +if redis.call('HGET', KEYS[1], '_tombstone') == '1' or redis.call('HGET', KEYS[1], 'deletedAt') then + return drop_stale() +end +if redis.call('HGET', KEYS[1], 'id') ~= ARGV[1] + or redis.call('HGET', KEYS[1], 'userId') ~= ARGV[3] + or redis.call('HGET', KEYS[1], 'routingFact') ~= ARGV[4] then + return drop_stale() +end +redis.call('ZADD', KEYS[2], ARGV[2], ARGV[1]) +return 1 +`; + +export interface RoutingFactReconcileResult { + ok: boolean; + /** + * set when !ok — distinguishes infrastructure failure from collection gap + * (sol R1 P1-1); 'malformed_provenance' (sol R4 P1-1c) = a window message + * carries a corrupt declaration, so cohort membership is unknowable and the + * window must read as unmeasurable instead of silently excluding it. + */ + reason?: + | 'redis_error' + | 'producer_gap' + | 'malformed_provenance' + | 'malformed_authority_fact' + | 'malformed_record' + | 'collection_gap'; + /** canonical validator rejected this many routingFact payloads */ + malformedFactCount?: number; + /** window messages that must carry a fact (routable-message cohort, producer-run audit) */ + cohortCount: number; + /** cohort messages that DO carry a fact (zero-token batches included) */ + authorityCount: number; + /** cohort messages missing the fact field — every one is a producer that did not run */ + producerGapCount: number; + projectedCount: number; + repairedMissing: number; + removedStale: number; +} + +interface ModeAggregate { + numerator: number; + denominator: number; + /** null when the denominator is 0 (no eligible attempts in window) */ + rate: number | null; + batches: number; +} + +export type ResolutionRateResult = + | { + unmeasurable: true; + reason: 'reconcile_failed' | 'producer_gap' | 'read_failed' | 'malformed_authority_fact'; + /** present for reconcile-derived unmeasurables — shows WHICH gap (sol R1 P1-1) */ + coverage?: RoutingFactReconcileResult; + /** present for reason='malformed_authority_fact' (sol R1 P1-3) */ + malformedFacts?: number; + } + | { + unmeasurable: false; + window: { fromTs: number; toTs: number }; + coverage: RoutingFactReconcileResult; + modes: Record; + /** batches excluded by batch-level metricEligible=false (T-A 右截断) */ + excludedBatches: number; + /** authority records whose fact field failed to parse — reported, never silently dropped */ + malformedFacts: number; + }; + +type ProjectableMessage = Pick; + +/** + * ioredis multi()/pipeline() exec() resolves per-command errors inside the + * result tuples instead of rejecting. Every projection write path must check + * them explicitly (sol R1 P1-5) — a null result array (aborted transaction) + * counts as failure too. + */ +function assertExecResultsOk(results: Array<[error: Error | null, result: unknown]> | null, context: string): void { + if (!results) { + throw new Error(`${context}: pipeline exec aborted (null result)`); + } + for (const [err] of results) { + if (err) throw err; + } +} + +export class RedisRoutingFactProjection { + private readonly redis: RedisClient; + + constructor(redis: RedisClient) { + this.redis = redis; + } + + /** + * Derive projection entries for one fact-carrying message (async worker path). + * Never throws — failures are logged and persisted to the error ZSET (§4.5.1③); + * reconcileWindow() repairs the gap before any evaluation reads the window. + */ + async project(msg: ProjectableMessage): Promise { + const fact = msg.routingFact; + // sol R1 P1-1: zero-token batches ARE authority records (producer-run marker) + // — indexing them keeps the coverage cohort complete-by-construction. + if (!fact) return; + const serializedFact = JSON.stringify(fact); + try { + await this.redis.eval( + PROJECT_ACTIVE_ROUTING_FACT_LUA, + 4, + MessageKeys.detail(msg.id), + RoutingFactKeys.index(msg.userId), + RoutingFactKeys.watermark(msg.userId), + RoutingFactKeys.projectionErrors(msg.userId), + msg.id, + msg.userId, + serializedFact, + ); + } catch (error) { + log.error({ error, messageId: msg.id, ownerUserId: msg.userId }, 'routing-fact projection write failed'); + try { + await this.redis.eval( + RECORD_ACTIVE_PROJECTION_ERROR_LUA, + 2, + MessageKeys.detail(msg.id), + RoutingFactKeys.projectionErrors(msg.userId), + msg.id, + String(Date.now()), + msg.userId, + serializedFact, + ); + } catch (markError) { + log.error({ markError, messageId: msg.id }, 'routing-fact projection error marker write failed'); + } + } + } + + /** + * Read the `routingFact` field for a list of message ids in one pipeline. + * Returns null on ANY read error — partial reads would silently bias counts. + */ + private async readFactPayloads(ids: readonly string[]): Promise | null> { + if (ids.length === 0) return []; + const pipeline = this.redis.pipeline(); + for (const id of ids) { + pipeline.hget(MessageKeys.detail(id), 'routingFact'); + } + const results = await pipeline.exec(); + if (!results || results.length !== ids.length) return null; + const payloads: Array = []; + for (const entry of results) { + const [err, value] = entry as [Error | null, unknown]; + if (err) return null; + payloads.push(typeof value === 'string' && value.length > 0 ? value : null); + } + return payloads; + } + + /** + * Read cohort-audit fields for a list of message ids in one pipeline + * (sol R3 P1-1). Returns null on ANY read error. Three-state provenance + * (sol R4 P1-1c): 'absent' = legacy pre-contract message (honestly out of + * cohort); 'malformed' = declaration present but corrupt — surfaced to the + * caller so the whole window reads unmeasurable, never silently excluded. + */ + private async readCohortRecords( + ownerUserId: string, + candidates: readonly { id: string; score: string }[], + ): Promise | null> { + if (candidates.length === 0) return []; + const pipeline = this.redis.pipeline(); + for (const candidate of candidates) { + pipeline.hmget( + MessageKeys.detail(candidate.id), + 'id', + 'threadId', + 'userId', + 'catId', + 'content', + 'mentions', + 'timestamp', + 'deliveredAt', + 'deletedAt', + 'deletedBy', + '_tombstone', + 'source', + 'routingFact', + 'provenance', + ); + } + const results = await pipeline.exec(); + if (!results || results.length !== candidates.length) return null; + const records: Array<{ + state: 'missing' | 'legacy' | 'deleted' | 'invalid' | 'present'; + routed: boolean; + invalidReason?: PersistedMessageInvalidReason; + routingFact?: string; + }> = []; + for (let index = 0; index < results.length; index += 1) { + const entry = results[index]; + const candidate = candidates[index]; + const [err, value] = entry as [Error | null, unknown]; + if (err || !Array.isArray(value)) return null; + const [ + storedId, + threadId, + userId, + catId, + content, + mentions, + timestamp, + deliveredAt, + deletedAt, + deletedBy, + tombstone, + source, + fact, + provenance, + ] = value as Array; + const parsed = parsePersistedMessageRecord({ + expectedId: candidate.id, + expectedOwnerUserId: ownerUserId, + expectedTimelineScore: candidate.score, + id: storedId, + threadId, + userId, + catId, + content, + mentions, + timestamp, + deliveredAt, + deletedAt, + deletedBy, + tombstone, + source, + routingFact: fact, + provenance, + }); + records.push({ + state: parsed.state, + routed: + (parsed.state === 'present' && parsed.provenance.routed) || + (parsed.state === 'invalid' && parsed.reason === 'routing_fact_missing'), + ...(parsed.state === 'invalid' ? { invalidReason: parsed.reason } : {}), + ...(parsed.state === 'present' && typeof fact === 'string' ? { routingFact: fact } : {}), + }); + } + return records; + } + + /** Idempotent index repair: add missing members, drop stale ones. */ + private async repairIndex( + ownerUserId: string, + missing: readonly { id: string; score: string; routingFact: string }[], + stale: readonly string[], + ): Promise<{ repairedMissing: number; removedStale: number }> { + if (missing.length === 0 && stale.length === 0) return { repairedMissing: 0, removedStale: 0 }; + const indexKey = RoutingFactKeys.index(ownerUserId); + const repair = this.redis.multi(); + for (const entry of missing) { + repair.eval( + PROJECT_ACTIVE_ROUTING_FACT_LUA, + 4, + MessageKeys.detail(entry.id), + indexKey, + RoutingFactKeys.watermark(ownerUserId), + RoutingFactKeys.projectionErrors(ownerUserId), + entry.id, + ownerUserId, + entry.routingFact, + ); + } + for (const id of stale) { + repair.zrem(indexKey, id); + } + // sol R1 P1-5: a swallowed repair failure would report a repaired window + // that is still broken; throwing routes to reconcileWindow's ok:false path. + const results = await repair.exec(); + assertExecResultsOk(results, 'repairIndex'); + const checkedResults = results ?? []; + return { + repairedMissing: checkedResults.slice(0, missing.length).filter(([, result]) => Number(result) === 1).length, + removedStale: checkedResults.slice(missing.length).filter(([, result]) => Number(result) > 0).length, + }; + } + + /** + * §4.5.1②: authority-vs-projection reconcile over [fromTs, toTs]. + * Authority enumeration = owner message timeline (written in the same append + * pipeline as the fact) filtered to hashes carrying a routingFact field. + * Idempotent: repairs missing members, removes stale ones. Any Redis error → + * { ok: false } and the caller must treat the window as unmeasurable. + */ + async reconcileWindow(ownerUserId: string, fromTs: number, toTs: number): Promise { + const failed: RoutingFactReconcileResult = { + ok: false, + reason: 'redis_error', + cohortCount: 0, + authorityCount: 0, + producerGapCount: 0, + projectedCount: 0, + repairedMissing: 0, + removedStale: 0, + }; + try { + const entries = await this.redis.zrangebyscore(MessageKeys.user(ownerUserId), fromTs, toTs, 'WITHSCORES'); + const candidates: Array<{ id: string; score: string }> = []; + for (let i = 0; i + 1 < entries.length; i += 2) { + candidates.push({ id: entries[i] as string, score: entries[i + 1] as string }); + } + + const records = await this.readCohortRecords(ownerUserId, candidates); + if (records === null) { + log.error({ ownerUserId }, 'routing-fact reconcile: authority read error'); + return failed; + } + + // sol R3 P1-1: cohort membership comes from the PERSISTED provenance the + // writer declared (routed axis) — never inferred from nullable fields and + // never from fact presence. The append boundary enforces routed ⇔ fact + // both ways (assertProvenanceConsistent), so a routed message without a + // fact here means an out-of-band write or a broken producer = gap. + // sol R4 P1-1c: a corrupt declaration anywhere in the window means the + // cohort boundary itself is unknowable — bail to unmeasurable BEFORE + // aggregating, instead of quietly treating the message as non-routed. + const missingCount = records.filter((record) => record.state === 'missing').length; + if (missingCount > 0) { + log.error({ ownerUserId, missingCount }, 'routing-fact reconcile: indexed message hash missing'); + return { ...failed, reason: 'collection_gap' }; + } + + const declarationReasons: readonly PersistedMessageInvalidReason[] = [ + 'malformed_provenance', + 'author_cat_id_conflict', + 'author_source_conflict', + 'routing_fact_unexpected', + ]; + const malformedDeclarationCount = records.filter( + (record) => + record.state === 'invalid' && + record.invalidReason !== undefined && + declarationReasons.includes(record.invalidReason), + ).length; + if (malformedDeclarationCount > 0) { + log.error( + { ownerUserId, malformedCount: malformedDeclarationCount }, + 'routing-fact reconcile: malformed provenance in window', + ); + return { ...failed, reason: 'malformed_provenance' }; + } + + const malformedFactCount = records.filter( + (record) => record.state === 'invalid' && record.invalidReason === 'malformed_routing_fact', + ).length; + if (malformedFactCount > 0) { + log.error({ ownerUserId, malformedFactCount }, 'routing-fact reconcile: malformed authority fact'); + return { ...failed, reason: 'malformed_authority_fact', malformedFactCount }; + } + + const malformedRecordCount = records.filter( + (record) => + record.state === 'invalid' && + record.invalidReason !== 'routing_fact_missing' && + record.invalidReason !== 'malformed_routing_fact', + ).length; + if (malformedRecordCount > 0) { + log.error({ ownerUserId, malformedRecordCount }, 'routing-fact reconcile: malformed authority record'); + return { ...failed, reason: 'malformed_record' }; + } + + const authority: Array<{ id: string; score: string; routingFact: string }> = []; + let cohortCount = 0; + let producerGapCount = 0; + for (let i = 0; i < candidates.length; i += 1) { + const record = records[i]; + if (!record.routed) continue; + cohortCount += 1; + if (record.state === 'invalid' && record.invalidReason === 'routing_fact_missing') { + producerGapCount += 1; + } else { + const candidate = candidates[i]; + if (candidate && record.routingFact) authority.push({ ...candidate, routingFact: record.routingFact }); + } + } + + const indexKey = RoutingFactKeys.index(ownerUserId); + const projected = new Set(await this.redis.zrangebyscore(indexKey, fromTs, toTs)); + const authorityIds = new Set(authority.map((entry) => entry.id)); + const missing = authority.filter((entry) => !projected.has(entry.id)); + const stale = [...projected].filter((id) => !authorityIds.has(id)); + + const repair = await this.repairIndex(ownerUserId, missing, stale); + if (repair.repairedMissing > 0 || repair.removedStale > 0) { + log.info({ ownerUserId, ...repair }, 'routing-fact projection reconciled'); + } + + const base = { + cohortCount, + authorityCount: authority.length, + producerGapCount, + projectedCount: projected.size, + repairedMissing: repair.repairedMissing, + removedStale: repair.removedStale, + }; + if (producerGapCount > 0) { + log.error({ ownerUserId, producerGapCount, cohortCount }, 'routing-fact reconcile: producer gap in window'); + return { ok: false, reason: 'producer_gap', ...base }; + } + return { ok: true, ...base }; + } catch (error) { + log.error({ error, ownerUserId }, 'routing-fact reconcile failed'); + return failed; + } + } + + /** T-A metric columns applied to one batch (mutates the matching mode aggregate). */ + private static applyBatch( + modes: Record, + batch: ReturnType, + counters: { excludedBatches: number; malformedFacts: number }, + ): void { + const mode = batch ? modes[batch.parserMode] : undefined; + if (!batch || !mode) { + counters.malformedFacts += 1; + return; + } + if (!batch.metricEligible) { + counters.excludedBatches += 1; + return; + } + mode.batches += 1; + for (const attempt of batch.attempts) { + if (!isMetricEligibleOutcome(attempt.outcome)) continue; + mode.denominator += 1; + if (isSuccessOutcome(attempt.outcome)) mode.numerator += 1; + } + } + + /** + * V1 active metric: @解析成功率 per parserMode over a reconciled window. + * Numerator/denominator/eligibility come from T-A via routing-attempt.ts + * mapping functions. Reconcile failure → unmeasurable (§4.5.1②). + */ + async computeResolutionRate(ownerUserId: string, fromTs: number, toTs: number): Promise { + const coverage = await this.reconcileWindow(ownerUserId, fromTs, toTs); + if (!coverage.ok) { + if (coverage.reason === 'malformed_authority_fact') { + return { + unmeasurable: true, + reason: 'malformed_authority_fact', + coverage, + malformedFacts: coverage.malformedFactCount ?? 1, + }; + } + return { + unmeasurable: true, + reason: coverage.reason === 'producer_gap' ? 'producer_gap' : 'reconcile_failed', + coverage, + }; + } + + try { + const ids = await this.redis.zrangebyscore(RoutingFactKeys.index(ownerUserId), fromTs, toTs); + const payloads = await this.readFactPayloads(ids); + if (payloads === null) return { unmeasurable: true, reason: 'read_failed' }; + + const modes: Record = { + a2a: { numerator: 0, denominator: 0, rate: null, batches: 0 }, + user: { numerator: 0, denominator: 0, rate: null, batches: 0 }, + }; + const counters = { excludedBatches: 0, malformedFacts: 0 }; + for (const payload of payloads) { + RedisRoutingFactProjection.applyBatch(modes, safeParseRoutingFact(payload ?? undefined), counters); + } + // sol R1 P1-3: an authority fact that fails full validation means the + // window's exact denominators cannot be trusted — no partial rate. + if (counters.malformedFacts > 0) { + return { + unmeasurable: true, + reason: 'malformed_authority_fact', + coverage, + malformedFacts: counters.malformedFacts, + }; + } + for (const mode of Object.values(modes)) { + mode.rate = mode.denominator > 0 ? mode.numerator / mode.denominator : null; + } + + return { + unmeasurable: false, + window: { fromTs, toTs }, + coverage, + modes, + excludedBatches: counters.excludedBatches, + malformedFacts: counters.malformedFacts, + }; + } catch (error) { + log.error({ error, ownerUserId }, 'routing-fact metric read failed'); + return { unmeasurable: true, reason: 'read_failed' }; + } + } + + /** Collection-health snapshot for the Console badge (§4.5.1① + ③ visibility). */ + async getHealth(ownerUserId: string): Promise<{ ok: boolean; watermark: string | null; errorCount: number }> { + try { + const [watermark, errorCount] = await Promise.all([ + this.redis.get(RoutingFactKeys.watermark(ownerUserId)), + this.redis.zcard(RoutingFactKeys.projectionErrors(ownerUserId)), + ]); + return { ok: true, watermark: watermark ?? null, errorCount }; + } catch (error) { + log.error({ error, ownerUserId }, 'routing-fact health read failed'); + return { ok: false, watermark: null, errorCount: 0 }; + } + } +} diff --git a/packages/api/src/domains/cats/services/stores/redis/redis-message-delivery-lua-scripts.ts b/packages/api/src/domains/cats/services/stores/redis/redis-message-delivery-lua-scripts.ts index 6e17d0e404..cd6d3721dd 100644 --- a/packages/api/src/domains/cats/services/stores/redis/redis-message-delivery-lua-scripts.ts +++ b/packages/api/src/domains/cats/services/stores/redis/redis-message-delivery-lua-scripts.ts @@ -1,5 +1,5 @@ /** - * Lua scripts for atomic delivery-order transitions and append (PR #1193 + #1200). + * Lua scripts for atomic delivery-order transitions (PR #1193). * * Bug: reassignUserId / markDelivered / markCanceled each read a JS snapshot * then write via independent MULTI — no shared atomic boundary. Two concurrent @@ -43,7 +43,7 @@ local deliveredAt = ARGV[2] local kp = ARGV[3] local status = redis.call('HGET', hash, 'deliveryStatus') -if status ~= 'queued' then +if redis.call('HGET', hash, '_tombstone') == '1' or status ~= 'queued' then return 0 end @@ -69,7 +69,7 @@ return redis.call('HGETALL', hash) export const CANCEL_LUA = ` local hash = KEYS[1] local status = redis.call('HGET', hash, 'deliveryStatus') -if status ~= 'queued' then +if redis.call('HGET', hash, '_tombstone') == '1' or status ~= 'queued' then return 0 end redis.call('HSET', hash, 'deliveryStatus', 'canceled') @@ -100,7 +100,7 @@ local kp = ARGV[3] local ttl = tonumber(ARGV[4]) local curUserId = redis.call('HGET', hash, 'userId') -if not curUserId then +if redis.call('HGET', hash, '_tombstone') == '1' or not curUserId then return -1 end if curUserId == nextUserId then @@ -123,101 +123,3 @@ end return redis.call('HGETALL', hash) `; - -/** - * APPEND: atomic idempotency claim + hash + all indexes + TTL cleanup. - * - * KEYS[1] = detail hash key (auto-prefixed by ioredis) - * ARGV[1] = message id - * ARGV[2] = JSON object of hash fields (id, threadId, userId, timestamp, content, ...) - * ARGV[3] = JSON array of mention catIds - * ARGV[4] = timeline/user/mentions/thread score (stringified message timestamp) - * ARGV[5] = idempotency key raw suffix (empty string if none) - * ARGV[6] = keyPrefix (e.g. "cat-cafe:") - * ARGV[7] = ttlSeconds as string ("0" = no expiry) - * - * Returns: existing message id on idempotency replay, otherwise the new message id. - */ -export const APPEND_LUA = ` -redis.replicate_commands() - -local hash = KEYS[1] -local msgId = ARGV[1] -local hashFields = cjson.decode(ARGV[2]) -local mentions = cjson.decode(ARGV[3]) -local score = tonumber(ARGV[4]) -local idemKeyRaw = ARGV[5] -local kp = ARGV[6] -local ttl = tonumber(ARGV[7]) - -local idemKey = idemKeyRaw ~= '' and (kp .. idemKeyRaw) or nil - --- Idempotency: if key points to a live hash, replay. -if idemKey then - local existingId = redis.call('GET', idemKey) - if existingId then - if redis.call('EXISTS', kp .. 'msg:' .. existingId) == 1 then - return existingId - end - -- stale reference: fall through to reclaim - end -end - --- Write hash. -local flat = {} -for k, v in pairs(hashFields) do - table.insert(flat, k) - table.insert(flat, v) -end -redis.call('HSET', hash, unpack(flat)) - --- Write time-semantic indexes. -local threadId = hashFields.threadId -local threadKey = kp .. 'msg:thread:' .. threadId -local userKey = kp .. 'msg:user:' .. hashFields.userId -local timelineKey = kp .. 'msg:timeline' -redis.call('ZADD', timelineKey, score, msgId) -redis.call('ZADD', userKey, score, msgId) -redis.call('ZADD', threadKey, score, msgId) -for _, catId in ipairs(mentions) do - redis.call('ZADD', kp .. 'msg:mentions:' .. catId, score, msgId) -end - --- Claim idempotency key and apply TTLs. -if idemKey then - -- Overwrite stale mappings (e.g., after deleteByThread deleted the hash but - -- left the idempotency key behind) as well as claiming a fresh key. At this - -- point the key either does not exist or points to a missing hash, so - -- unconditional SET is safe and prevents duplicate creation on retries. - redis.call('SET', idemKey, msgId) - if ttl > 0 then - redis.call('EXPIRE', idemKey, ttl) - end -end - -if ttl > 0 then - redis.call('EXPIRE', hash, ttl) - redis.call('EXPIRE', timelineKey, ttl) - redis.call('EXPIRE', userKey, ttl) - redis.call('EXPIRE', threadKey, ttl) - for _, catId in ipairs(mentions) do - redis.call('EXPIRE', kp .. 'msg:mentions:' .. catId, ttl) - end - - local timeArr = redis.call('TIME') - local timeMs = tonumber(timeArr[1]) * 1000 + math.floor(tonumber(timeArr[2]) / 1000) - local cutoff = timeMs - ttl * 1000 - redis.call('ZREMRANGEBYSCORE', timelineKey, '-inf', cutoff) - redis.call('ZREMRANGEBYSCORE', userKey, '-inf', cutoff) - for _, catId in ipairs(mentions) do - redis.call('ZREMRANGEBYSCORE', kp .. 'msg:mentions:' .. catId, '-inf', cutoff) - end - -- Thread ZSET is scored by timestamp (queued) or deliveredAt. Prune members - -- whose score is older than the TTL window so active threads do not accumulate - -- unhydratable IDs indefinitely. Whole-key EXPIRE remains as a backstop for - -- idle threads. - redis.call('ZREMRANGEBYSCORE', threadKey, '-inf', cutoff) -end - -return msgId -`; diff --git a/packages/api/src/domains/cats/services/stores/redis/redis-message-parsers.ts b/packages/api/src/domains/cats/services/stores/redis/redis-message-parsers.ts index ec0ece0f73..d859a9d018 100644 --- a/packages/api/src/domains/cats/services/stores/redis/redis-message-parsers.ts +++ b/packages/api/src/domains/cats/services/stores/redis/redis-message-parsers.ts @@ -5,8 +5,306 @@ */ import type { CatId, ConnectorSource, MessageContent, RichMessageExtra } from '@cat-cafe/shared'; +import { isValidRoutingAttemptBatch, type RoutingAttemptBatch } from '../../agents/routing/routing-attempt.js'; import type { MessageMetadata } from '../../types.js'; -import type { StoredMessage, StoredToolEvent } from '../ports/MessageStore.js'; +import { + type MessageProvenance, + PROVENANCE_AUTHORS, + PROVENANCE_OBSERVATIONS, + type StoredMessage, + type StoredToolEvent, +} from '../ports/MessageStore.js'; + +/** + * F257 V1 (sol R3 P1-1, three-state sol R4 P1-1c): writer-declared provenance + * read path. 'absent' (legacy message written before the contract) and + * 'malformed' (field present but corrupt — storage/writer fault) are DIFFERENT + * facts: legacy messages honestly predate every cohort, while a malformed + * declaration means the window's cohort membership is unknowable and metric + * consumers must report the window unmeasurable instead of silently shrinking + * the cohort. + */ +export type ProvenanceFieldParse = + | { state: 'absent' } + | { state: 'malformed' } + | { state: 'present'; provenance: MessageProvenance }; + +export function parseProvenanceField(raw: string | undefined | null): ProvenanceFieldParse { + if (raw === undefined || raw === null) return { state: 'absent' }; + try { + const parsed = JSON.parse(raw) as { + author?: unknown; + routed?: unknown; + observation?: unknown; + sourceRef?: unknown; + }; + if (!parsed || typeof parsed !== 'object') return { state: 'malformed' }; + if (!(PROVENANCE_AUTHORS as readonly unknown[]).includes(parsed.author)) return { state: 'malformed' }; + if (typeof parsed.routed !== 'boolean') return { state: 'malformed' }; + if (!(PROVENANCE_OBSERVATIONS as readonly unknown[]).includes(parsed.observation)) { + return { state: 'malformed' }; + } + if ( + parsed.observation === 'derived' && + (typeof parsed.sourceRef !== 'string' || parsed.sourceRef.trim().length === 0) + ) { + return { state: 'malformed' }; + } + if (parsed.observation === 'original' && parsed.sourceRef !== undefined) return { state: 'malformed' }; + return { + state: 'present', + provenance: { + author: parsed.author as MessageProvenance['author'], + routed: parsed.routed, + observation: parsed.observation as MessageProvenance['observation'], + ...(parsed.observation === 'derived' ? { sourceRef: parsed.sourceRef as string } : {}), + }, + }; + } catch { + return { state: 'malformed' }; + } +} + +export type PersistedMessageInvalidReason = + | 'required_field_missing' + | 'coordinate_mismatch' + | 'malformed_timestamp' + | 'malformed_delivered_at' + | 'malformed_deleted_at' + | 'malformed_tombstone' + | 'malformed_mentions' + | 'malformed_source' + | 'malformed_routing_fact' + | 'malformed_provenance' + | 'author_cat_id_conflict' + | 'author_source_conflict' + | 'routing_fact_missing' + | 'routing_fact_unexpected' + | 'tombstone_payload_present'; + +export interface ParsedPersistedMessageRecord { + id: string; + threadId: string; + userId: string; + catId: CatId | null; + content: string; + mentions: readonly CatId[]; + timestamp: number; + deliveredAt?: number; + /** owner/thread timeline coordinate: delivery position when delivered, send position otherwise */ + effectiveOrderAt: number; + source?: ConnectorSource; + routingFact?: RoutingAttemptBatch; + deletedAt?: number; +} + +export type PersistedMessageRecordParse = + | { state: 'missing' } + | { state: 'legacy'; record: ParsedPersistedMessageRecord } + | { state: 'deleted'; deletion: 'soft' | 'hard'; record: ParsedPersistedMessageRecord } + | { state: 'invalid'; reason: PersistedMessageInvalidReason } + | { state: 'present'; record: ParsedPersistedMessageRecord; provenance: MessageProvenance }; + +/** + * Canonical read-side mirror of assertProvenanceConsistent(). Exact metrics + * consume this whole-record validator instead of independently interpreting a + * subset of fields. A missing hash is distinct from a hash that legitimately + * predates provenance; present-but-empty fields are corruption, not legacy. + */ +export function parsePersistedMessageRecord(fields: { + expectedId: string; + expectedOwnerUserId: string; + expectedTimelineScore: string; + id: string | undefined | null; + threadId: string | undefined | null; + userId: string | undefined | null; + catId: string | undefined | null; + content: string | undefined | null; + mentions: string | undefined | null; + timestamp: string | undefined | null; + deliveredAt: string | undefined | null; + deletedAt: string | undefined | null; + deletedBy: string | undefined | null; + tombstone: string | undefined | null; + source: string | undefined | null; + routingFact: string | undefined | null; + provenance: string | undefined | null; +}): PersistedMessageRecordParse { + const rawValues = [ + fields.id, + fields.threadId, + fields.userId, + fields.catId, + fields.content, + fields.mentions, + fields.timestamp, + fields.deliveredAt, + fields.deletedAt, + fields.deletedBy, + fields.tombstone, + fields.source, + fields.routingFact, + fields.provenance, + ]; + if (rawValues.every((value) => value === undefined || value === null)) return { state: 'missing' }; + if ( + typeof fields.id !== 'string' || + fields.id.length === 0 || + typeof fields.threadId !== 'string' || + fields.threadId.length === 0 || + typeof fields.userId !== 'string' || + fields.userId.length === 0 || + typeof fields.catId !== 'string' || + typeof fields.content !== 'string' || + typeof fields.mentions !== 'string' || + typeof fields.timestamp !== 'string' + ) { + return { state: 'invalid', reason: 'required_field_missing' }; + } + + if (fields.id !== fields.expectedId || fields.userId !== fields.expectedOwnerUserId) { + return { state: 'invalid', reason: 'coordinate_mismatch' }; + } + + if (!/^(0|[1-9]\d*)$/.test(fields.timestamp)) { + return { state: 'invalid', reason: 'malformed_timestamp' }; + } + const timestamp = Number(fields.timestamp); + const timelineScore = Number(fields.expectedTimelineScore); + if (!Number.isSafeInteger(timestamp) || timestamp < 0 || !Number.isFinite(timelineScore)) { + return { state: 'invalid', reason: 'malformed_timestamp' }; + } + + const deliveredAtPresent = fields.deliveredAt !== undefined && fields.deliveredAt !== null; + if (deliveredAtPresent && !/^(0|[1-9]\d*)$/.test(fields.deliveredAt ?? '')) { + return { state: 'invalid', reason: 'malformed_delivered_at' }; + } + const deliveredAt = deliveredAtPresent ? Number(fields.deliveredAt) : undefined; + if (deliveredAt !== undefined && (!Number.isSafeInteger(deliveredAt) || deliveredAt < 0)) { + return { state: 'invalid', reason: 'malformed_delivered_at' }; + } + const effectiveOrderAt = deliveredAt ?? timestamp; + if (effectiveOrderAt !== timelineScore) return { state: 'invalid', reason: 'coordinate_mismatch' }; + + const deletedAtPresent = fields.deletedAt !== undefined && fields.deletedAt !== null; + if (deletedAtPresent && !/^(0|[1-9]\d*)$/.test(fields.deletedAt ?? '')) { + return { state: 'invalid', reason: 'malformed_deleted_at' }; + } + const deletedAt = deletedAtPresent ? Number(fields.deletedAt) : undefined; + if (deletedAt !== undefined && (!Number.isSafeInteger(deletedAt) || deletedAt < 0)) { + return { state: 'invalid', reason: 'malformed_deleted_at' }; + } + const tombstonePresent = fields.tombstone !== undefined && fields.tombstone !== null; + const deletedByPresent = fields.deletedBy !== undefined && fields.deletedBy !== null; + if (tombstonePresent && fields.tombstone !== '1') { + return { state: 'invalid', reason: 'malformed_tombstone' }; + } + if ( + (deletedAtPresent && (typeof fields.deletedBy !== 'string' || fields.deletedBy.length === 0)) || + (!deletedAtPresent && (deletedByPresent || tombstonePresent)) + ) { + return { state: 'invalid', reason: tombstonePresent ? 'malformed_tombstone' : 'malformed_deleted_at' }; + } + + let mentions: readonly CatId[]; + try { + const parsedMentions: unknown = JSON.parse(fields.mentions); + if (!Array.isArray(parsedMentions) || !parsedMentions.every((mention) => typeof mention === 'string')) { + return { state: 'invalid', reason: 'malformed_mentions' }; + } + mentions = parsedMentions as unknown as readonly CatId[]; + } catch { + return { state: 'invalid', reason: 'malformed_mentions' }; + } + + const sourcePresent = fields.source !== undefined && fields.source !== null; + const source = sourcePresent ? safeParseConnectorSource(fields.source ?? undefined) : undefined; + if (sourcePresent && !source) return { state: 'invalid', reason: 'malformed_source' }; + + const factPresent = fields.routingFact !== undefined && fields.routingFact !== null; + const routingFact = factPresent ? safeParseRoutingFact(fields.routingFact ?? undefined) : undefined; + if (factPresent && !routingFact) return { state: 'invalid', reason: 'malformed_routing_fact' }; + + const record: ParsedPersistedMessageRecord = { + id: fields.id, + threadId: fields.threadId, + userId: fields.userId, + catId: fields.catId ? (fields.catId as CatId) : null, + content: fields.content, + mentions, + timestamp, + ...(deliveredAt !== undefined ? { deliveredAt } : {}), + effectiveOrderAt, + ...(source ? { source } : {}), + ...(routingFact ? { routingFact } : {}), + ...(deletedAt !== undefined ? { deletedAt } : {}), + }; + if (deletedAt !== undefined) { + if (tombstonePresent) { + if ( + fields.content !== '' || + mentions.length !== 0 || + (fields.routingFact !== undefined && fields.routingFact !== null) || + (fields.provenance !== undefined && fields.provenance !== null) + ) { + return { state: 'invalid', reason: 'tombstone_payload_present' }; + } + return { state: 'deleted', deletion: 'hard', record }; + } + return { state: 'deleted', deletion: 'soft', record }; + } + const parsed = parseProvenanceField(fields.provenance); + if (parsed.state === 'absent') { + return factPresent ? { state: 'invalid', reason: 'routing_fact_unexpected' } : { state: 'legacy', record }; + } + if (parsed.state === 'malformed') return { state: 'invalid', reason: 'malformed_provenance' }; + + const catIdPresent = typeof fields.catId === 'string' && fields.catId.length > 0; + if ( + ((parsed.provenance.author === 'user' || parsed.provenance.author === 'external_user') && catIdPresent) || + (parsed.provenance.author === 'cat' && !catIdPresent) + ) { + return { state: 'invalid', reason: 'author_cat_id_conflict' }; + } + if ( + (parsed.provenance.author === 'user' && sourcePresent) || + (parsed.provenance.author === 'external_user' && !sourcePresent) + ) { + return { state: 'invalid', reason: 'author_source_conflict' }; + } + + if (parsed.provenance.routed && !factPresent) return { state: 'invalid', reason: 'routing_fact_missing' }; + if (!parsed.provenance.routed && factPresent) return { state: 'invalid', reason: 'routing_fact_unexpected' }; + return { state: 'present', record, provenance: parsed.provenance }; +} + +/** + * Hydration projection of parseProvenanceField for StoredMessage surfaces + * (UI/API reads): both 'absent' and 'malformed' hydrate as "no trusted + * declaration" (undefined). Metric/reconcile consumers MUST NOT use this — + * they consume parsePersistedMessageRecord so whole-record contradictions + * surface as unmeasurable windows. + */ +export function hydrateProvenance(raw: string | undefined | null): MessageProvenance | undefined { + const parsed = parseProvenanceField(raw); + return parsed.state === 'present' ? parsed.provenance : undefined; +} + +/** + * F257 V1: embedded RoutingDecisionFact payload (schema: routing-attempt.ts, + * semantics: T-A §3.4). Full structural validation (sol R1 P1-3) — a payload + * failing any field check returns undefined so consumers count it as + * malformed instead of partially aggregating it. + */ +export function safeParseRoutingFact(raw: string | undefined): RoutingAttemptBatch | undefined { + if (!raw) return undefined; + try { + const parsed = JSON.parse(raw) as unknown; + return isValidRoutingAttemptBatch(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} export function safeParseMentions(raw: string | undefined): readonly CatId[] { if (!raw) return []; @@ -65,6 +363,8 @@ export function safeParseExtra(raw: string | undefined): isExplicitPost?: boolean; tracing?: { traceId: string; spanId: string; parentSpanId?: string }; systemKind?: 'a2a_routing' | 'context_briefing'; + // F257 #4 (sol R1 P1-2): preserve signature lint through Redis round-trip. + signatureLint?: { signed: boolean }; } | undefined { if (!raw) return undefined; @@ -95,6 +395,7 @@ export function safeParseExtra(raw: string | undefined): tracing?: { traceId: string; spanId: string; parentSpanId?: string }; systemKind?: 'a2a_routing' | 'context_briefing'; a2aRouting?: { fromCatId?: string; targetCatId?: string; invocationId?: string }; + signatureLint?: { signed: boolean }; } = {}; let hasField = false; @@ -166,6 +467,16 @@ export function safeParseExtra(raw: string | undefined): hasField = true; } + // F257 #4 (sol R1 P1-2): preserve signature lint verdict through Redis round-trip. + if ( + parsed.signatureLint && + typeof parsed.signatureLint === 'object' && + typeof parsed.signatureLint.signed === 'boolean' + ) { + result.signatureLint = { signed: parsed.signatureLint.signed }; + hasField = true; + } + if (parsed.a2aRouting && typeof parsed.a2aRouting === 'object') { const routing: NonNullable = {}; if (typeof parsed.a2aRouting.fromCatId === 'string') routing.fromCatId = parsed.a2aRouting.fromCatId; diff --git a/packages/api/src/domains/cats/services/types.ts b/packages/api/src/domains/cats/services/types.ts index 0c437ea39a..aac72420bc 100644 --- a/packages/api/src/domains/cats/services/types.ts +++ b/packages/api/src/domains/cats/services/types.ts @@ -201,6 +201,8 @@ export interface AgentMessage { targetCats?: string[]; /** #814: True when message originated from an explicit post_message callback (not stream duplicate) */ isExplicitPost?: boolean; + /** F257 #4 (sol R1 P2-1): message-signature lint verdict, forwarded to live delivery. */ + signatureLint?: { signed: boolean }; }; /** F121: ID of the message this message is replying to */ replyTo?: string; diff --git a/packages/api/src/domains/memory/EventMemoryStore.ts b/packages/api/src/domains/memory/EventMemoryStore.ts index 2b82ba07fc..3376a2052d 100644 --- a/packages/api/src/domains/memory/EventMemoryStore.ts +++ b/packages/api/src/domains/memory/EventMemoryStore.ts @@ -11,7 +11,7 @@ * (enum | null) is stored verbatim. */ -import { appendFileSync, existsSync, readFileSync } from 'node:fs'; +import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; import type { CognitiveTransition, EventConfidence, @@ -61,6 +61,10 @@ export interface IEventMemoryStore { listEvents(filter?: EventMemoryFilter): StoredEventMemory[]; /** Teleport reverse lookup: events at a (threadId, messageId) coordinate, owner-scoped when provided. */ getByCoord(threadId: string, messageId: string, ownerUserId?: string): StoredEventMemory[]; + /** Hard-delete privacy boundary: remove every event/excerpt at this message coordinate. */ + deleteByCoord(threadId: string, messageId: string): number; + /** Physical thread deletion: remove every event/excerpt belonging to the thread. */ + deleteByThread(threadId: string): number; /** P1-3 (砚砚): persist a failed write + its owner scope for replay so events are not lost (最终不丢). */ appendDeadLetter(record: EventMemoryRecord, ownerUserId: string, errorMessage: string): void; /** Read dead-lettered entries (replay / inspection). */ @@ -118,6 +122,17 @@ export class EventMemoryStore implements IEventMemoryStore { CREATE INDEX IF NOT EXISTS idx_event_trigger ON event_memory(trigger_type); CREATE INDEX IF NOT EXISTS idx_event_timestamp ON event_memory(timestamp); CREATE INDEX IF NOT EXISTS idx_event_confidence ON event_memory(confidence); + + CREATE TABLE IF NOT EXISTS event_memory_deleted_coords ( + threadId TEXT NOT NULL, + messageId TEXT NOT NULL, + deletedAt INTEGER NOT NULL, + PRIMARY KEY (threadId, messageId) + ); + CREATE TABLE IF NOT EXISTS event_memory_deleted_threads ( + threadId TEXT PRIMARY KEY, + deletedAt INTEGER NOT NULL + ); `); // F227 (cloud-review P1): owner scope. A legacy table (pre-owner) lacks the column — // add it so initialize() upgrades in place. Existing un-owned rows get '' and stay @@ -149,6 +164,21 @@ export class EventMemoryStore implements IEventMemoryStore { return this.db; } + private assertWritable(db: InstanceType, threadId: string, messageId: string): void { + const deletedThread = db + .prepare('SELECT 1 FROM event_memory_deleted_threads WHERE threadId = ? LIMIT 1') + .get(threadId); + if (deletedThread) { + throw new Error(`EventMemoryStore: deleted thread write rejected (${threadId})`); + } + const deletedCoordinate = db + .prepare('SELECT 1 FROM event_memory_deleted_coords WHERE threadId = ? AND messageId = ? LIMIT 1') + .get(threadId, messageId); + if (deletedCoordinate) { + throw new Error(`EventMemoryStore: deleted coordinate write rejected (${threadId}/${messageId})`); + } + } + markEvent(record: EventMemoryRecord, ownerUserId: string): MarkEventResult { // 砚砚 (non-blocking): validate untrusted payloads (backfill / tool writers) // with the shared guard before they hit SQLite. @@ -161,65 +191,69 @@ export class EventMemoryStore implements IEventMemoryStore { throw new Error('EventMemoryStore.markEvent: ownerUserId is required (no fallback)'); } const db = this.ensureOpen(); - const eventId = generateEventId(); - // INSERT OR IGNORE against UNIQUE(ownerUserId, threadId, messageId, type): atomically - // idempotent, so concurrent backfill / live writes on the same coordinate can't - // double-write. - const info = db - .prepare( - `INSERT OR IGNORE INTO event_memory + return db.transaction((): MarkEventResult => { + this.assertWritable(db, record.threadId, record.messageId); + const eventId = generateEventId(); + // INSERT OR IGNORE against UNIQUE(ownerUserId, threadId, messageId, type): atomically + // idempotent, so concurrent backfill / live writes on the same coordinate can't + // double-write. The delete-fence check is in this SAME transaction: a writer holding + // a stale Redis snapshot cannot recreate private text after deletion linearizes. + const info = db + .prepare( + `INSERT OR IGNORE INTO event_memory (eventId, type, trigger_type, cat, ownerUserId, threadId, messageId, timestamp, summary, cognitiveTransition, relatedHarness, confidence) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - eventId, - record.type, + ) + .run( + eventId, + record.type, + record.trigger, + record.cat, + ownerUserId, + record.threadId, + record.messageId, + record.timestamp, + record.summary, + record.cognitiveTransition, + record.relatedHarness === null ? null : JSON.stringify(record.relatedHarness), + record.confidence, + ); + if (info.changes === 1) { + return { event: { eventId, ownerUserId, ...record }, inserted: true }; + } + // Duplicate (ownerUserId, threadId, messageId, type) already present — no new row. + // Race resolution (cloud-review P2): if THIS writer has STRICTLY higher confidence than + // the existing row, upgrade its confidence + metadata. So a real live brake (high) is + // never left at a backfill grade (mid/low) just because backfill won the insert race; + // lower/equal confidence leaves the existing row untouched (idempotent). + db.prepare( + `UPDATE event_memory + SET confidence = ?, trigger_type = ?, cat = ?, summary = ?, cognitiveTransition = ?, relatedHarness = ? + WHERE ownerUserId = ? AND threadId = ? AND messageId = ? AND type = ? + AND (CASE ? WHEN 'high' THEN 3 WHEN 'mid' THEN 2 ELSE 1 END) + > (CASE confidence WHEN 'high' THEN 3 WHEN 'mid' THEN 2 ELSE 1 END)`, + ).run( + record.confidence, record.trigger, record.cat, - ownerUserId, - record.threadId, - record.messageId, - record.timestamp, record.summary, record.cognitiveTransition, record.relatedHarness === null ? null : JSON.stringify(record.relatedHarness), + ownerUserId, + record.threadId, + record.messageId, + record.type, record.confidence, ); - if (info.changes === 1) { - return { event: { eventId, ownerUserId, ...record }, inserted: true }; - } - // Duplicate (ownerUserId, threadId, messageId, type) already present — no new row. - // Race resolution (cloud-review P2): if THIS writer has STRICTLY higher confidence than - // the existing row, upgrade its confidence + metadata. So a real live brake (high) is - // never left at a backfill grade (mid/low) just because backfill won the insert race; - // lower/equal confidence leaves the existing row untouched (idempotent). - db.prepare( - `UPDATE event_memory - SET confidence = ?, trigger_type = ?, cat = ?, summary = ?, cognitiveTransition = ?, relatedHarness = ? - WHERE ownerUserId = ? AND threadId = ? AND messageId = ? AND type = ? - AND (CASE ? WHEN 'high' THEN 3 WHEN 'mid' THEN 2 ELSE 1 END) - > (CASE confidence WHEN 'high' THEN 3 WHEN 'mid' THEN 2 ELSE 1 END)`, - ).run( - record.confidence, - record.trigger, - record.cat, - record.summary, - record.cognitiveTransition, - record.relatedHarness === null ? null : JSON.stringify(record.relatedHarness), - ownerUserId, - record.threadId, - record.messageId, - record.type, - record.confidence, - ); - // Return the existing (possibly just-upgraded) event so the live path still resolves a - // real eventId (砚砚); no duplicate row is ever written. - const existing = db - .prepare( - 'SELECT * FROM event_memory WHERE ownerUserId = ? AND threadId = ? AND messageId = ? AND type = ? LIMIT 1', - ) - .get(ownerUserId, record.threadId, record.messageId, record.type) as Record | undefined; - return { event: existing ? this.rowToEvent(existing) : { eventId, ownerUserId, ...record }, inserted: false }; + // Return the existing (possibly just-upgraded) event so the live path still resolves a + // real eventId (砚砚); no duplicate row is ever written. + const existing = db + .prepare( + 'SELECT * FROM event_memory WHERE ownerUserId = ? AND threadId = ? AND messageId = ? AND type = ? LIMIT 1', + ) + .get(ownerUserId, record.threadId, record.messageId, record.type) as Record | undefined; + return { event: existing ? this.rowToEvent(existing) : { eventId, ownerUserId, ...record }, inserted: false }; + })(); } getEvent(eventId: string): StoredEventMemory | null { @@ -277,6 +311,63 @@ export class EventMemoryStore implements IEventMemoryStore { return rows.map((r) => this.rowToEvent(r)); } + deleteByCoord(threadId: string, messageId: string): number { + const db = this.ensureOpen(); + return db.transaction(() => { + db.prepare( + `INSERT INTO event_memory_deleted_coords (threadId, messageId, deletedAt) + VALUES (?, ?, ?) + ON CONFLICT(threadId, messageId) DO NOTHING`, + ).run(threadId, messageId, Date.now()); + const result = db + .prepare('DELETE FROM event_memory WHERE threadId = ? AND messageId = ?') + .run(threadId, messageId); + return ( + result.changes + this.deleteDeadLetters((entry) => entry.threadId === threadId && entry.messageId === messageId) + ); + })(); + } + + deleteByThread(threadId: string): number { + const db = this.ensureOpen(); + return db.transaction(() => { + db.prepare( + `INSERT INTO event_memory_deleted_threads (threadId, deletedAt) + VALUES (?, ?) + ON CONFLICT(threadId) DO NOTHING`, + ).run(threadId, Date.now()); + db.prepare('DELETE FROM event_memory_deleted_coords WHERE threadId = ?').run(threadId); + const result = db.prepare('DELETE FROM event_memory WHERE threadId = ?').run(threadId); + return result.changes + this.deleteDeadLetters((entry) => entry.threadId === threadId); + })(); + } + + private deleteDeadLetters(matches: (record: EventMemoryRecord) => boolean): number { + const lines = this.deadLetterPath + ? existsSync(this.deadLetterPath) + ? readFileSync(this.deadLetterPath, 'utf8').split('\n').filter(Boolean) + : [] + : [...this.inMemoryDeadLetter]; + const retained: string[] = []; + let removed = 0; + for (const line of lines) { + const entry = JSON.parse(line) as DeadLetterEntry; + if (matches(entry.record)) { + removed += 1; + } else { + retained.push(line); + } + } + if (this.deadLetterPath) { + if (existsSync(this.deadLetterPath)) { + writeFileSync(this.deadLetterPath, retained.length > 0 ? `${retained.join('\n')}\n` : '', 'utf8'); + } + } else { + this.inMemoryDeadLetter.splice(0, this.inMemoryDeadLetter.length, ...retained); + } + return removed; + } + health(): boolean { try { this.ensureOpen().prepare('SELECT 1').get(); @@ -287,12 +378,22 @@ export class EventMemoryStore implements IEventMemoryStore { } appendDeadLetter(record: EventMemoryRecord, ownerUserId: string, errorMessage: string): void { - const line = `${JSON.stringify({ record, ownerUserId, error: errorMessage, failedAt: Date.now() })}\n`; - if (this.deadLetterPath) { - appendFileSync(this.deadLetterPath, line); - } else { - this.inMemoryDeadLetter.push(line); + if (!isEventMemoryRecord(record)) { + throw new Error('EventMemoryStore.appendDeadLetter: record failed isEventMemoryRecord guard'); + } + if (!isValidOwnerUserId(ownerUserId)) { + throw new Error('EventMemoryStore.appendDeadLetter: ownerUserId is required (no fallback)'); } + const db = this.ensureOpen(); + db.transaction(() => { + this.assertWritable(db, record.threadId, record.messageId); + const line = `${JSON.stringify({ record, ownerUserId, error: errorMessage, failedAt: Date.now() })}\n`; + if (this.deadLetterPath) { + appendFileSync(this.deadLetterPath, line); + } else { + this.inMemoryDeadLetter.push(line); + } + })(); } listDeadLetter(): DeadLetterEntry[] { diff --git a/packages/api/src/domains/memory/schema.ts b/packages/api/src/domains/memory/schema.ts index 7283adb49c..26d0203ef6 100644 --- a/packages/api/src/domains/memory/schema.ts +++ b/packages/api/src/domains/memory/schema.ts @@ -66,7 +66,7 @@ END`, END`, ]; -export const CURRENT_SCHEMA_VERSION = 26; +export const CURRENT_SCHEMA_VERSION = 27; // F163 Phase A: experiment infrastructure tables (cohorts, suggestions, logs) export const SCHEMA_V13_TABLES = ` @@ -744,6 +744,15 @@ export function applyMigrations(db: Database.Database): void { } catch {} db.prepare('INSERT INTO schema_version (version, applied_at) VALUES (?, ?)').run(26, new Date().toISOString()); } + + // V27: F257 scheduler provenance — persist RUN_FAILED retry progress for once-tasks + // so that a restart during the backoff window does not lose the task as a missed window. + if (currentVersion < 27) { + try { + db.exec('ALTER TABLE dynamic_task_defs ADD COLUMN retry_attempts INTEGER DEFAULT 0'); + } catch {} + db.prepare('INSERT INTO schema_version (version, applied_at) VALUES (?, ?)').run(27, new Date().toISOString()); + } } /** diff --git a/packages/api/src/domains/prompt-hooks/HookOverrideStore.ts b/packages/api/src/domains/prompt-hooks/HookOverrideStore.ts new file mode 100644 index 0000000000..788e8aedcd --- /dev/null +++ b/packages/api/src/domains/prompt-hooks/HookOverrideStore.ts @@ -0,0 +1,319 @@ +/** + * HookOverrideStore — Redis-backed per-workspace override layer for prompt hooks. + * Enforces safetyTier/disableable gating via internal manifest lookup (codex P1, PR #22). + * + * Storage: HASH hook-override:{ws}, ZSET events, KEY event detail (TTL=0). + * Event recording + reconciliation extracted to hook-override-event-recorder.ts. + */ + +import type { HookManifest, HookOverride, HookOverrideSource, OverrideChangeEvent } from '@cat-cafe/shared'; +import type { RedisClient } from '@cat-cafe/shared/utils'; +import { HookOverrideEventRecorder, reconcileOverride } from './hook-override-event-recorder.js'; + +/** Resolves a HookManifest by hookId. Returns undefined for unknown hooks. */ +export type ManifestLookup = (hookId: string) => HookManifest | undefined; + +const OVERRIDE_HASH = (ws: string) => `hook-override:${ws}`; +/** P1-3: per-version content snapshot. HASH {epochVersion → content}. */ +const VERSION_SNAPSHOT = (ws: string, hookId: string) => `hook-override-versions:${ws}:${hookId}`; +/** R7: atomic epoch counter for race-safe epochVersion assignment. */ +const EPOCH_COUNTER = (ws: string, hookId: string) => `hook-override-epoch-seq:${ws}:${hookId}`; + +/** Thrown when an override operation violates manifest safety constraints. */ +export class OverrideGateError extends Error { + constructor( + public readonly hookId: string, + public readonly action: string, + public readonly gate: 'disableable' | 'safetyTier' | 'unknown-hook', + public readonly manifestValue: string | boolean, + ) { + super(`Override rejected: hook '${hookId}' ${action} blocked by ${gate}=${String(manifestValue)}`); + this.name = 'OverrideGateError'; + } +} + +export class HookOverrideStore { + private readonly events: HookOverrideEventRecorder; + + constructor( + private readonly redis: RedisClient, + private readonly manifestLookup: ManifestLookup, + private readonly defaultWorkspaceId = 'default', + ) { + this.events = new HookOverrideEventRecorder(redis); + } + + // -- Manifest resolution (fail-closed) ------------------------------------ + + private resolveManifest(hookId: string): HookManifest { + const manifest = this.manifestLookup(hookId); + if (!manifest) { + throw new OverrideGateError(hookId, 'resolve', 'unknown-hook', 'not-found'); + } + return manifest; + } + + private assertDisableable(hookId: string): void { + const manifest = this.resolveManifest(hookId); + if (!manifest.disableable) { + throw new OverrideGateError(hookId, 'disable', 'disableable', false); + } + } + + private assertContentEditable(hookId: string, source: HookOverrideSource): void { + const manifest = this.resolveManifest(hookId); + if (manifest.safetyTier === 'readonly') { + throw new OverrideGateError(hookId, 'content-set', 'safetyTier', 'readonly'); + } + if (manifest.safetyTier === 'limited-edit' && source !== 'operator') { + throw new OverrideGateError(hookId, 'content-set', 'safetyTier', 'limited-edit'); + } + } + + // -- Write operations ----------------------------------------------------- + + async enable( + hookId: string, + actorId: string, + opts?: { source?: HookOverrideSource; workspaceId?: string; reason?: string }, + ): Promise { + this.resolveManifest(hookId); + const ws = opts?.workspaceId ?? this.defaultWorkspaceId; + const source = opts?.source ?? 'operator'; + const existing = await this.getOverride(hookId, ws); + const override: HookOverride = { + ...(existing ?? {}), + hookId, + enabled: true, + enabledSource: source, + source, + updatedAt: Date.now(), + updatedBy: actorId, + }; + await this.redis.hset(OVERRIDE_HASH(ws), hookId, JSON.stringify(override)); + await this.events.record(ws, hookId, 'enable', source, actorId, opts?.reason); + } + + async disable( + hookId: string, + actorId: string, + opts?: { source?: HookOverrideSource; workspaceId?: string; reason?: string }, + ): Promise { + this.assertDisableable(hookId); + const ws = opts?.workspaceId ?? this.defaultWorkspaceId; + const source = opts?.source ?? 'operator'; + const existing = await this.getOverride(hookId, ws); + const override: HookOverride = { + ...(existing ?? {}), + hookId, + enabled: false, + enabledSource: source, + source, + updatedAt: Date.now(), + updatedBy: actorId, + }; + await this.redis.hset(OVERRIDE_HASH(ws), hookId, JSON.stringify(override)); + await this.events.record(ws, hookId, 'disable', source, actorId, opts?.reason); + } + + async setContentOverride( + hookId: string, + content: string, + actorId: string, + opts?: { source?: HookOverrideSource; workspaceId?: string; reason?: string }, + ): Promise { + const source = opts?.source ?? 'operator'; + this.assertContentEditable(hookId, source); + const ws = opts?.workspaceId ?? this.defaultWorkspaceId; + const existing = await this.getOverride(hookId, ws); + + // epochVersion: monotonic, never resets (I1, I3). max(manifest, max_snapshot_key) + 1. + const manifest = this.resolveManifest(hookId); + const epochVersion = await this.nextEpochVersion(ws, hookId, manifest.version); + + const override: HookOverride = { + ...(existing ?? {}), + hookId, + contentOverride: content, + contentVersion: (existing?.contentVersion ?? 0) + 1, + activeEpochVersion: epochVersion, + contentSource: source, + source, + updatedAt: Date.now(), + updatedBy: actorId, + }; + await this.redis.hset(OVERRIDE_HASH(ws), hookId, JSON.stringify(override)); + // Snapshot keyed by epochVersion (not contentVersion) — append-only (I4) + await this.redis.hset(VERSION_SNAPSHOT(ws, hookId), String(epochVersion), content); + await this.events.record( + ws, + hookId, + 'content-set', + source, + actorId, + opts?.reason, + override.contentVersion, + epochVersion, + ); + } + + async clearContentOverride( + hookId: string, + actorId: string, + opts?: { source?: HookOverrideSource; workspaceId?: string; reason?: string }, + ): Promise { + this.resolveManifest(hookId); + const ws = opts?.workspaceId ?? this.defaultWorkspaceId; + const source = opts?.source ?? 'operator'; + const existing = await this.getOverride(hookId, ws); + if (!existing) return; + const { contentOverride: _, contentVersion: __, contentSource: _cs, activeEpochVersion: _aev, ...rest } = existing; + const override: HookOverride = { + ...rest, + source, + updatedAt: Date.now(), + updatedBy: actorId, + }; + await this.redis.hset(OVERRIDE_HASH(ws), hookId, JSON.stringify(override)); + await this.events.record(ws, hookId, 'content-clear', source, actorId, opts?.reason); + } + + async rollback( + hookId: string, + actorId: string, + opts?: { source?: HookOverrideSource; workspaceId?: string; reason?: string }, + ): Promise { + // Fail-closed: unknown hooks must not write audit events (terra P2, F257). + this.resolveManifest(hookId); + const ws = opts?.workspaceId ?? this.defaultWorkspaceId; + const source = opts?.source ?? 'operator'; + await this.redis.hdel(OVERRIDE_HASH(ws), hookId); + await this.events.record(ws, hookId, 'rollback', source, actorId, opts?.reason); + } + + // -- P1-3: Version management ------------------------------------------- + + /** Activate a version by epochVersion (stable monotonic ID, not contentVersion). */ + async activateVersion( + hookId: string, + epochVersion: number, + actorId: string, + opts?: { source?: HookOverrideSource; workspaceId?: string; reason?: string }, + ): Promise { + this.resolveManifest(hookId); + const source = opts?.source ?? 'operator'; + this.assertContentEditable(hookId, source); + const ws = opts?.workspaceId ?? this.defaultWorkspaceId; + + const content = await this.redis.hget(VERSION_SNAPSHOT(ws, hookId), String(epochVersion)); + if (content === null) { + throw new Error(`No content snapshot for hook '${hookId}' epochVersion ${epochVersion}`); + } + + // Restore content; do NOT reset contentVersion (edit counter, not identity) + const existing = await this.getOverride(hookId, ws); + const override: HookOverride = { + ...(existing ?? {}), + hookId, + contentOverride: content, + contentVersion: existing?.contentVersion ?? 1, + activeEpochVersion: epochVersion, + contentSource: source, + source, + updatedAt: Date.now(), + updatedBy: actorId, + }; + await this.redis.hset(OVERRIDE_HASH(ws), hookId, JSON.stringify(override)); + await this.events.record(ws, hookId, 'version-activate', source, actorId, opts?.reason, undefined, epochVersion); + } + + /** List all stored version snapshots for a hook. */ + async listVersions( + hookId: string, + workspaceId?: string, + ): Promise> { + const ws = workspaceId ?? this.defaultWorkspaceId; + const all = await this.redis.hgetall(VERSION_SNAPSHOT(ws, hookId)); + if (!all) return []; + return Object.entries(all) + .map(([v, content]) => ({ + version: Number(v), + contentPreview: content.length > 120 ? `${content.slice(0, 120)}…` : content, + })) + .sort((a, b) => a.version - b.version); + } + + // -- Read operations ------------------------------------------------------ + + async getOverride(hookId: string, workspaceId?: string): Promise { + const raw = await this.redis.hget(OVERRIDE_HASH(workspaceId ?? this.defaultWorkspaceId), hookId); + if (!raw) return null; + try { + return JSON.parse(raw) as HookOverride; + } catch { + return null; + } + } + + async listOverrides(workspaceId?: string): Promise { + const all = await this.redis.hgetall(OVERRIDE_HASH(workspaceId ?? this.defaultWorkspaceId)); + if (!all) return []; + const results: HookOverride[] = []; + for (const v of Object.values(all)) { + try { + results.push(JSON.parse(v) as HookOverride); + } catch { + /* skip corrupted */ + } + } + return results; + } + + /** + * Load overrides as a sync Map for pipeline hot-path resolution. + * Reconciles against current manifest (sol P1-1): tightened constraints + * strip stale override fields. + */ + async loadSnapshot(workspaceId?: string): Promise> { + const overrides = await this.listOverrides(workspaceId); + const result = new Map(); + for (const override of overrides) { + const reconciled = reconcileOverride(override, this.manifestLookup); + if (reconciled) { + result.set(reconciled.hookId, reconciled); + } + } + return result; + } + + // -- Event stream --------------------------------------------------------- + + async listEvents(opts?: { + workspaceId?: string; + limit?: number; + since?: number; + until?: number; + }): Promise { + return this.events.list(opts?.workspaceId ?? this.defaultWorkspaceId, opts); + } + + // -- Internal helpers ----------------------------------------------------- + + /** + * Compute next monotonic epochVersion (I3, R7 atomicity fix). + * + * Uses SETNX + INCR for atomic counter: two concurrent setContentOverride() + * calls will always get distinct epoch versions. SETNX initializes the counter + * from max(manifestVersion, max_snapshot_key) on first use; INCR is atomic. + */ + private async nextEpochVersion(ws: string, hookId: string, manifestVersion: number): Promise { + const counterKey = EPOCH_COUNTER(ws, hookId); + // Initialize counter from snapshot state if it doesn't exist yet (SETNX = atomic) + const all = await this.redis.hgetall(VERSION_SNAPSHOT(ws, hookId)); + const maxSnapshot = all ? Math.max(0, ...Object.keys(all).map(Number)) : 0; + const initial = Math.max(manifestVersion, maxSnapshot); + await this.redis.setnx(counterKey, String(initial)); + // INCR: atomic increment, returns new value — safe under concurrency + return await this.redis.incr(counterKey); + } +} diff --git a/packages/api/src/domains/prompt-hooks/HookPipeline.ts b/packages/api/src/domains/prompt-hooks/HookPipeline.ts index fa85b9b906..e30724ea17 100644 --- a/packages/api/src/domains/prompt-hooks/HookPipeline.ts +++ b/packages/api/src/domains/prompt-hooks/HookPipeline.ts @@ -20,6 +20,7 @@ import type { PromptPatch, RegisteredHook, ResolveResult, + SegmentContentSourceKind, TraceEvent, TraceEventDisabled, TraceEventFired, @@ -98,25 +99,47 @@ export class HookPipeline { } /** - * Render content for a fired hook: CONTENT passthrough → template → fallback. - * Returns null if no template found (caller emits template_missing trace). + * Render content for a fired hook: + * 1. Content override from HookOverrideStore (PR3) — highest priority + * 2. CONTENT var passthrough from resolver + * 3. Template rendering → fallback template + * Returns null if no content source found (caller emits template_missing trace). */ - private renderContent(hook: RegisteredHook, templateId: string, vars: Record): string | null { + private renderContent( + hook: RegisteredHook, + templateId: string, + vars: Record, + ): { content: string; sourceKind: SegmentContentSourceKind; sourceRef: string } | null { + // PR3: content override takes precedence over all other sources + const contentOverride = this.registry.getContentOverride(hook.manifest.id); + if (contentOverride !== undefined) { + return { content: contentOverride, sourceKind: 'override', sourceRef: hook.manifest.id }; + } + // Resolver-produced content passthrough: when the resolver provides a CONTENT // var, it signals that the final rendered content is already assembled // (e.g., S6 breed-specific workflow triggers, S13 pre-rendered MCP tools // section). Skip template rendering — the template file may be a data source // (YAML) or expect vars that only the legacy path provides. - if (vars.CONTENT) return vars.CONTENT; - return this.renderer(templateId, vars) ?? this.renderFromTemplatePath(hook, vars); + if (vars.CONTENT) { + return { content: vars.CONTENT, sourceKind: 'content-var', sourceRef: `${hook.manifest.id}:CONTENT` }; + } + + const rendered = this.renderer(templateId, vars); + if (rendered) return { content: rendered, sourceKind: 'template', sourceRef: templateId }; + + const fallback = this.renderFromTemplatePath(hook, vars); + if (fallback) return { content: fallback, sourceKind: 'file-fallback', sourceRef: hook.templatePath }; + + return null; } /** * Execute all hooks for a stage in manifest order. * Each hook: enabled check → resolve → render → patch + trace. * - * Uses manifest baseline for enabled/version. Runtime overrides - * (HookOverrideStore) will be added in a separate PR. + * Checks registry.isEnabled() which resolves override snapshot → manifest baseline. + * Content overrides from HookOverrideStore take precedence over template rendering. */ executeStage(stage: HookStage, input: AssemblerInput): PipelineResult { const hooks = this.registry.getStageHooks(stage); @@ -127,14 +150,14 @@ export class HookPipeline { const hookId = hook.manifest.id; const ts = Date.now(); - // 1. Enabled check — manifest baseline - if (!hook.manifest.enabled) { + // 1. Enabled check — override snapshot → manifest baseline (PR3) + if (!this.registry.isEnabled(hookId)) { events.push({ hookId, stage, timestamp: ts, status: 'disabled', - disabledBy: 'manifest', + disabledBy: this.registry.getDisabledBySource(hookId), } as TraceEventDisabled); continue; } @@ -157,8 +180,8 @@ export class HookPipeline { // 3. Resolve template variant + render content const templateId = result.vars.TEMPLATE_VARIANT ?? hookId; - const content = this.renderContent(hook, templateId, result.vars); - if (!content) { + const rendered = this.renderContent(hook, templateId, result.vars); + if (!rendered) { events.push({ hookId, stage, @@ -170,16 +193,21 @@ export class HookPipeline { continue; } - // 4. Produce patch + trace (manifest version) - patches.push({ hookId, content, order: hook.manifest.order }); + // 4. Produce patch + trace (override version → manifest version) + patches.push({ hookId, content: rendered.content, order: hook.manifest.order }); events.push({ hookId, stage, timestamp: ts, status: 'fired', - version: hook.manifest.version, - contentHash: hashContent(content), - tokenEstimate: estimateTokens(content), + version: this.registry.getActiveVersion(hookId), + contentHash: hashContent(rendered.content), + tokenEstimate: estimateTokens(rendered.content), + // F257 Console 判据④:persist event-time rendered content + source provenance for replay. + content: rendered.content, + contentSourceKind: rendered.sourceKind, + templateRef: rendered.sourceRef, + templateVars: result.vars, } as TraceEventFired); } diff --git a/packages/api/src/domains/prompt-hooks/HookRegistry.ts b/packages/api/src/domains/prompt-hooks/HookRegistry.ts index 978daed96f..31db200b0e 100644 --- a/packages/api/src/domains/prompt-hooks/HookRegistry.ts +++ b/packages/api/src/domains/prompt-hooks/HookRegistry.ts @@ -8,13 +8,21 @@ import { existsSync, lstatSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; -import type { HookManifest, HookStage, RegisteredHook } from '@cat-cafe/shared'; +import type { + HookManifest, + HookOverride, + HookOverrideSnapshot, + HookStage, + RegisteredHook, + TraceEventDisabled, +} from '@cat-cafe/shared'; import { parseHookManifest } from './hook-manifest-parser.js'; export class HookRegistry { private hooks = new Map(); private readonly hooksDir: string; private readonly templatesDir: string | null; + private overrideSnapshot: HookOverrideSnapshot | null = null; /** * @param hooksDir - Directory containing hook subdirectories (each with hook.yaml) @@ -129,18 +137,103 @@ export class HookRegistry { return [...this.hooks.values()]; } - /** Check if hook is enabled (baseline only — override resolution in P2-D). */ + // --------------------------------------------------------------------------- + // Override snapshot (PR3: loaded async, used sync in pipeline hot-path) + // --------------------------------------------------------------------------- + + /** Set the override snapshot for sync resolution during pipeline execution. */ + setOverrideSnapshot(snapshot: HookOverrideSnapshot): void { + this.overrideSnapshot = snapshot; + } + + /** Clear override snapshot (e.g., between invocations). */ + clearOverrideSnapshot(): void { + this.overrideSnapshot = null; + } + + /** Get raw override for a hook (null = no override). */ + getOverride(hookId: string): HookOverride | null { + return this.overrideSnapshot?.get(hookId) ?? null; + } + + // --------------------------------------------------------------------------- + // Override-aware queries (manifest baseline + override layer) + // --------------------------------------------------------------------------- + + /** + * Check if hook is enabled (override takes precedence over manifest, + * but only if the current manifest permits it — defense-in-depth against + * stale overrides surviving manifest security tightening, sol P1-1). + */ isEnabled(hookId: string): boolean { + const override = this.overrideSnapshot?.get(hookId); const hook = this.hooks.get(hookId); + if (override?.enabled === false) { + // Only honor disable-override if manifest currently allows disabling + return hook?.manifest.disableable ? false : (hook?.manifest.enabled ?? false); + } + if (override?.enabled === true) return true; return hook?.manifest.enabled ?? false; } - /** Get active version (baseline only — override resolution in P2-D). */ + /** + * Get active version — the version that matches what the pipeline actually renders. + * Only reports override contentVersion when the content override is actually + * honored by getContentOverride(); otherwise falls back to manifest version. + * + * Sol P2 fix: without this guard, trace reports stale contentVersion (e.g. v99) + * while rendering uses baseline (v1) — polluting F257 version diff evidence. + */ getActiveVersion(hookId: string): number { + // Delegate to getContentOverride() for consistency — it already enforces + // readonly / limited-edit + contentSource provenance checks. + if (this.getContentOverride(hookId) !== undefined) { + const override = this.overrideSnapshot?.get(hookId); + // R7: prefer activeEpochVersion (stable monotonic ID) over contentVersion + // (mutable edit counter). activeEpochVersion is set by setContentOverride + // and activateVersion, cleared by rollback/clear. + if (override?.activeEpochVersion !== undefined) return override.activeEpochVersion; + if (override?.contentVersion !== undefined) return override.contentVersion; + } const hook = this.hooks.get(hookId); return hook?.manifest.version ?? 0; } + /** + * Get content override for a hook (undefined = use manifest template). + * Defense-in-depth against stale overrides surviving manifest tightening: + * - readonly: always ignore content override (sol round 1) + * - limited-edit: only honor operator-sourced content (sol round 2). + * Uses contentSource (field-level provenance), NOT source which can be + * corrupted by unrelated enable/disable operations. + */ + getContentOverride(hookId: string): string | undefined { + const hook = this.hooks.get(hookId); + if (hook?.manifest.safetyTier === 'readonly') return undefined; + const override = this.overrideSnapshot?.get(hookId); + if (!override?.contentOverride) return undefined; + if (hook?.manifest.safetyTier === 'limited-edit' && override.contentSource !== 'operator') { + return undefined; + } + return override.contentOverride; + } + + /** + * Determine who disabled this hook — for TraceEventDisabled.disabledBy. + * Uses enabledSource (field-level provenance) when available, falls back + * to source for backward compat with pre-provenance overrides. + * If manifest doesn't allow disabling, a stale override is ignored. + */ + getDisabledBySource(hookId: string): TraceEventDisabled['disabledBy'] { + const override = this.overrideSnapshot?.get(hookId); + const hook = this.hooks.get(hookId); + if (override?.enabled === false && hook?.manifest.disableable) { + const effectiveSource = override.enabledSource ?? override.source; + return effectiveSource === 'auto-eval' ? 'auto-eval' : 'operator'; + } + return 'manifest'; + } + /** Total number of registered hooks. */ get size(): number { return this.hooks.size; diff --git a/packages/api/src/domains/prompt-hooks/InjectionTraceStore.ts b/packages/api/src/domains/prompt-hooks/InjectionTraceStore.ts index 657f84718f..8b6da885f6 100644 --- a/packages/api/src/domains/prompt-hooks/InjectionTraceStore.ts +++ b/packages/api/src/domains/prompt-hooks/InjectionTraceStore.ts @@ -6,12 +6,26 @@ * Layer 2: InjectionTraceDetail — short TTL (default 7 days) */ -import type { InjectionTraceDetail, InjectionTraceSummary } from '@cat-cafe/shared'; +import type { InjectionTraceDetail, InjectionTraceSummary, ReplaySnapshot } from '@cat-cafe/shared'; import type { RedisClient } from '@cat-cafe/shared/utils'; const SUMMARY_PREFIX = 'injection-trace-summary:'; const DETAIL_PREFIX = 'injection-trace-detail:'; const INDEX_PREFIX = 'injection-trace-index:'; +const REPLAY_SNAPSHOT_PREFIX = 'replay-snapshot:'; +/** + * F257 Phase D: Registry of thread IDs with trace data. + * Uses a Redis SET (SADD/SMEMBERS) instead of SCAN because ioredis keyPrefix + * does NOT apply to SCAN MATCH patterns — SADD/SMEMBERS respect keyPrefix. + * Populated on every persist() call; read by listTracedThreadIds(). + */ +const THREAD_REGISTRY_KEY = 'injection-trace-thread-registry'; +/** + * Durable marker: set to '1' after the one-time backfill SCAN succeeds. + * Decoupled from registry contents — new persist() SADDs don't prevent + * legacy threads from being discovered (terra review P1, 2026-07-14). + */ +const BACKFILL_DONE_KEY = 'injection-trace-backfill-done'; function summaryKey(threadId: string, turnId: string): string { return `${SUMMARY_PREFIX}${threadId}:${turnId}`; @@ -22,11 +36,68 @@ function detailKey(threadId: string, turnId: string): string { function indexKey(threadId: string): string { return `${INDEX_PREFIX}${threadId}`; } +function replaySnapshotHashKey(threadId: string, turnId: string): string { + return `${REPLAY_SNAPSHOT_PREFIX}${threadId}:${turnId}`; +} const DEFAULT_DETAIL_TTL_SECONDS = 7 * 24 * 60 * 60; +/** + * F257 R4: Atomic write of durable replay snapshots. + * + * KEYS[1] = summary key (CAS token; ioredis auto-prepends keyPrefix) + * KEYS[2] = replay snapshot hash key + * ARGV[1] = number of snapshots (N) + * ARGV[2..N+1] = segmentId + * ARGV[N+2..2N+1] = JSON snapshot + * + * Returns 1 on success, 0 if the turn has been deleted (no resurrection). + */ +const PERSIST_REPLAY_SNAPSHOTS_LUA = ` +local summaryKey = KEYS[1] +local hashKey = KEYS[2] +local count = tonumber(ARGV[1]) + +if redis.call('EXISTS', summaryKey) == 0 then + return 0 +end + +for i = 1, count do + local segmentId = ARGV[1 + i] + local json = ARGV[1 + count + i] + redis.call('HSET', hashKey, segmentId, json) +end + +return 1 +`; + +/** + * F257 R4: Atomic delete of all trace data for a turn. + * + * KEYS[1] = summary key + * KEYS[2] = detail key + * KEYS[3] = turn index sorted-set key + * KEYS[4] = replay snapshot hash key + * ARGV[1] = turnId + * + * Removes the turn from the shared thread index via ZREM and deletes the + * turn-private summary/detail/snapshot-hash keys. Sibling turns remain indexed. + */ +const DELETE_TURN_LUA = ` +local summaryKey = KEYS[1] +local detailKey = KEYS[2] +local indexKey = KEYS[3] +local hashKey = KEYS[4] +local turnId = ARGV[1] + +local removedFromIndex = redis.call('ZREM', indexKey, turnId) +local deletedKeys = redis.call('DEL', summaryKey, detailKey, hashKey) +return removedFromIndex + deletedKeys +`; + export class InjectionTraceStore { private readonly detailTtl: number; + private backfillPromise: Promise | null = null; constructor( private readonly redis: RedisClient, @@ -42,6 +113,8 @@ export class InjectionTraceStore { await this.redis.set(sKey, JSON.stringify(summary)); await this.redis.set(dKey, JSON.stringify(detail), 'EX', this.detailTtl); await this.redis.zadd(iKey, summary.timestamp, summary.turnId); + // F257 Phase D: register thread in discovery SET (SADD respects keyPrefix; SCAN does not). + await this.redis.sadd(THREAD_REGISTRY_KEY, summary.threadId); } async getSummary(threadId: string, turnId: string): Promise { @@ -89,9 +162,141 @@ export class InjectionTraceStore { return { summaries, total }; } + /** + * F257: Time-windowed query for judgment engine consumption. + * + * Returns summaries within [startMs, endMs) for a given thread. + * End-exclusive to match GuardRejectionEventLog.queryWindow boundary contract + * and prevent double-counting in adjacent eval windows. + * + * The judgment engine uses this to compute per-segment injectionCount: + * queryWindow(threadId, windowStart, windowEnd) → filter segments by segmentId → count fired. + */ + async queryWindow(threadId: string, startMs: number, endMs: number): Promise { + const iKey = indexKey(threadId); + // End-exclusive: ZRANGEBYSCORE is inclusive, so subtract 1ms to implement [start, end). + // Matches GuardRejectionEventLog.queryWindow (line 177: `const upperBound = until - 1`). + const turnIds = await this.redis.zrangebyscore(iKey, startMs, endMs - 1); + const summaries: InjectionTraceSummary[] = []; + for (const turnId of turnIds) { + const summary = await this.getSummary(threadId, turnId); + if (summary) summaries.push(summary); + } + return summaries; + } + + /** + * F257 Phase D: One-time backfill of the thread registry SET from pre-existing + * index keys. Handles the cold-start gap: traces persisted before the registry + * SET was added have index sorted sets but no SADD entry. + * + * Uses prefix-aware SCAN (ioredis keyPrefix does NOT apply to SCAN MATCH, + * so we manually prepend the prefix). Controlled by a durable marker key + * (BACKFILL_DONE_KEY) — NOT by registry emptiness, because new persist() + * calls SADD new threads before backfill runs, making "registry non-empty" + * an unreliable signal (terra review P1, 2026-07-14). + * + * Called lazily on first listTracedThreadIds() — runs once per process lifetime. + * Marker is set only after success; failure allows retry. + */ + private async backfillRegistry(): Promise { + const done = await this.redis.get(BACKFILL_DONE_KEY); + if (done) return; + + const prefix = this.redis.options?.keyPrefix ?? ''; + const pattern = `${prefix}${INDEX_PREFIX}*`; + const prefixLen = prefix.length + INDEX_PREFIX.length; + const discovered = new Set(); + + let cursor = '0'; + do { + // Type assertion: ioredis scan overloads cause circular inference in do-while + const result = (await this.redis.scan(cursor, 'MATCH', pattern, 'COUNT', 200)) as [string, string[]]; + cursor = result[0]; + for (const key of result[1]) { + const threadId = key.slice(prefixLen); + if (threadId) discovered.add(threadId); + } + } while (cursor !== '0'); + + if (discovered.size > 0) { + await this.redis.sadd(THREAD_REGISTRY_KEY, ...discovered); + } + // Mark backfill as complete — only after successful SCAN. + // No TTL: marker persists forever (backfill is a one-time migration). + await this.redis.set(BACKFILL_DONE_KEY, '1'); + } + + /** + * F257 Phase D: Discover all thread IDs that have injection trace data. + * Used by the segment lifeline endpoint to scan across all threads. + * + * Primary: Redis SET (SMEMBERS) populated by persist() SADD calls. + * Fallback: one-time backfill via prefix-aware SCAN for pre-existing data. + * SADD/SMEMBERS respect ioredis keyPrefix; SCAN MATCH does not. + */ + async listTracedThreadIds(): Promise { + if (!this.backfillPromise) { + this.backfillPromise = this.backfillRegistry().catch(() => { + this.backfillPromise = null; // Allow retry on transient failure + }); + } + await this.backfillPromise; + return this.redis.smembers(THREAD_REGISTRY_KEY); + } + + /** + * F257 Console 判据④:atomically delete all trace data for a turn. + * + * Uses a single Lua script so summary/detail/index/snapshot-hash are removed + * in one Redis execution — no window where a late snapshot writer can observe + * a partially deleted turn and resurrect data. + */ async deleteTurn(threadId: string, turnId: string): Promise { - await this.redis.del(summaryKey(threadId, turnId)); - await this.redis.del(detailKey(threadId, turnId)); - await this.redis.zrem(indexKey(threadId), turnId); + await this.redis.eval( + DELETE_TURN_LUA, + 4, + summaryKey(threadId, turnId), + detailKey(threadId, turnId), + indexKey(threadId), + replaySnapshotHashKey(threadId, turnId), + turnId, + ); + } + + /** + * F257 Console 判据④:persist durable, owner-scoped replay snapshots for a turn. + * + * TTL=0 by default — user-visible recoverable data. Stored as a single Redis + * hash per turn so delete is one atomic key removal. The Lua script checks the + * turn summary still exists before writing; if deleteTurn() won the race, the + * write is suppressed and snapshots are not resurrected. + */ + async persistReplaySnapshots(threadId: string, turnId: string, snapshots: ReplaySnapshot[]): Promise { + if (snapshots.length === 0) return; + const args: string[] = [String(snapshots.length)]; + const jsons: string[] = []; + for (const snapshot of snapshots) { + args.push(snapshot.segmentId); + jsons.push(JSON.stringify(snapshot)); + } + args.push(...jsons); + await this.redis.eval( + PERSIST_REPLAY_SNAPSHOTS_LUA, + 2, + summaryKey(threadId, turnId), + replaySnapshotHashKey(threadId, turnId), + ...args, + ); + } + + async getReplaySnapshot(threadId: string, turnId: string, segmentId: string): Promise { + const raw = await this.redis.hget(replaySnapshotHashKey(threadId, turnId), segmentId); + if (!raw) return null; + try { + return JSON.parse(raw) as ReplaySnapshot; + } catch { + return null; + } } } diff --git a/packages/api/src/domains/prompt-hooks/PipelinePromptBuilder.ts b/packages/api/src/domains/prompt-hooks/PipelinePromptBuilder.ts index 9fc83f83eb..9851e4bd0b 100644 --- a/packages/api/src/domains/prompt-hooks/PipelinePromptBuilder.ts +++ b/packages/api/src/domains/prompt-hooks/PipelinePromptBuilder.ts @@ -10,7 +10,7 @@ * * Lazy-initializes a singleton HookPipeline (scan-once, reuse across calls). * Pipeline output equals legacy output (AC-P2-14 zero behavior change). - * Runtime overrides (HookOverrideStore) will be added in a separate PR. + * Runtime overrides injected via setOverrideStore() at bootstrap (PR3). */ import { join } from 'node:path'; @@ -20,6 +20,7 @@ import { renderSegment } from '../cats/services/context/prompt-template-loader.j import type { InvocationContext, StaticIdentityOptions } from '../cats/services/context/SystemPromptBuilder.js'; import { buildConciergePromptLines } from '../concierge/ConciergePromptSection.js'; import { assembleForSession, assembleForTurn } from './assemble-bridge.js'; +import type { HookOverrideStore } from './HookOverrideStore.js'; import { HookPipeline, type PipelineResult } from './HookPipeline.js'; import { HookRegistry } from './HookRegistry.js'; import { RESOLVER_MAP } from './resolvers/index.js'; @@ -77,6 +78,42 @@ export function getCachedRegistry(): HookRegistry | null { return cachedRegistry; } +// --------------------------------------------------------------------------- +// Override store wiring (PR3: HookOverrideStore → HookRegistry snapshot) +// --------------------------------------------------------------------------- + +let cachedOverrideStore: HookOverrideStore | null = null; + +/** + * Set the override store reference (called once at bootstrap). + * The store is used by `refreshOverrideSnapshot()` to load per-workspace + * overrides into the registry before each prompt build. + */ +export function setOverrideStore(store: HookOverrideStore): void { + cachedOverrideStore = store; +} + +/** + * Load the current override snapshot from Redis and inject it into the registry. + * Must be called (await) before any synchronous pipeline execution — the registry + * resolves overrides synchronously from the snapshot, so it must be pre-loaded. + * + * Forces lazy pipeline init if needed (cold-start: registry may not exist yet + * when this is called before the first buildStaticIdentity). + * + * No-ops gracefully if no store is configured (e.g., Redis unavailable). + */ +export async function refreshOverrideSnapshot(workspaceId?: string): Promise { + if (!cachedOverrideStore) return; + // Ensure pipeline singleton is initialized — getPipeline() is idempotent, + // but on cold start cachedRegistry is null until first getPipeline() call. + // Without this, the first invocation's refreshOverrideSnapshot() no-ops + // and the first prompt build misses all overrides. + if (!cachedRegistry) getPipeline(); + const snapshot = await cachedOverrideStore.loadSnapshot(workspaceId); + cachedRegistry!.setOverrideSnapshot(snapshot); +} + // --------------------------------------------------------------------------- // Trace capture (AC-P2-8): last pipeline traces for invocation-layer persistence // --------------------------------------------------------------------------- diff --git a/packages/api/src/domains/prompt-hooks/SegmentJudgmentCache.ts b/packages/api/src/domains/prompt-hooks/SegmentJudgmentCache.ts new file mode 100644 index 0000000000..a737a32399 --- /dev/null +++ b/packages/api/src/domains/prompt-hooks/SegmentJudgmentCache.ts @@ -0,0 +1,207 @@ +/** + * SegmentJudgmentCache — F257 Phase D + * + * Lightweight Redis cache for the latest per-segment judgment results. + * + * The segment-judgment-engine produces verdicts during eval runs, but results + * are transient (formatted into eval cat evidence text, never persisted). + * This cache stores the latest judgment per segment so the lifeline API can + * show eval stage data without re-running the judgment engine. + * + * Storage: Redis HASH — one field per segmentId, value = JSON(CachedJudgment). + * Written after each eval run, read by GET /api/segment-lifeline/:segmentId. + */ + +import type { ProvenanceGapKind } from '@cat-cafe/shared'; +import type { RedisClient } from '@cat-cafe/shared/utils'; +import type { SegmentJudgment, SegmentVerdict } from '../../infrastructure/harness-eval/segment-judgment-engine.js'; + +const CACHE_KEY = 'segment-judgment-latest'; +/** Per-segment ZSET storing all judgment history, scored by evaluatedAt. P1-2. */ +const HISTORY_KEY = (segmentId: string) => `segment-judgment-history:${segmentId}`; + +/** + * Subset of SegmentJudgment stored in the cache — only what the lifeline needs. + * + * 判据② (F257 #6 slice 6c): `window` + `denominatorKind` are REQUIRED on every + * producer write — the judgment engine always has them, so the write path + * cannot omit them. `null` is reserved for ONE case: legacy Redis JSON written + * before slice 6c, normalized on read (fail-visible provenance gap — never + * guessed from `evaluatedAt`, never silently replaced by the query window). + */ +export interface CachedJudgment { + segmentId: string; + verdict: SegmentVerdict; + injectionCount: number; + violationCount: number; + correlationConfidence: string; + evaluatedAt: number; + runId: string; + /** Version of the segment when judgment was produced. Used for epoch attribution. */ + segmentVersion: number | null; + /** The judgment's OWN eval sampling window [startMs, endMs). null = gap (see windowGap). */ + window: { startMs: number; endMs: number } | null; + /** Why window is null: legacy entry never had it vs present-but-malformed (sol R5 P2). */ + windowGap: ProvenanceGapKind | null; + /** Denominator semantics of the counts. null = gap (see denominatorGap). */ + denominatorKind: 'fired-count' | 'session-count' | 'none' | null; + /** Why denominatorKind is null: legacy-missing vs invalid-present (sol R5 P2). */ + denominatorGap: ProvenanceGapKind | null; +} + +/** Closed union of denominator semantics — anything off-domain is malformed, not "unknown". */ +const DENOMINATOR_KINDS = new Set(['fired-count', 'session-count', 'none']); + +/** + * 判据② P2-1 (sol R1) + P2 (sol R6): validate a PRESENT window field at the + * Redis read boundary. Present-but-malformed (incl. explicit null — the + * producer never writes null) → invalid-present, never a trusted coordinate: + * a forged window reaching the UI renders `Invalid Date ~ Invalid Date` and + * fakes a coordinate — worse than an honest gap. Absence is classified by the + * CALLER via own-property presence (absent = legacy-missing). + */ +function validatePresentWindow(raw: unknown): { + window: { startMs: number; endMs: number } | null; + gap: ProvenanceGapKind | null; +} { + const invalid = { window: null, gap: 'invalid-present' as const }; + if (raw == null) return invalid; // explicit null / undefined value = malformed-present (sol R6 P2) + if (typeof raw !== 'object' || Array.isArray(raw)) return invalid; + const w = raw as { startMs?: unknown; endMs?: unknown }; + if (typeof w.startMs !== 'number' || typeof w.endMs !== 'number') return invalid; + if (!Number.isFinite(w.startMs) || !Number.isFinite(w.endMs)) return invalid; + if (w.startMs >= w.endMs) return invalid; // [startMs,endMs) must be non-empty (zero-length = malformed, sol R4 P2-1) + return { window: { startMs: w.startMs, endMs: w.endMs }, gap: null }; +} + +/** denominatorKind: only the closed union survives; anything else present → invalid-present. */ +function validatePresentDenominatorKind(raw: unknown): { + kind: CachedJudgment['denominatorKind']; + gap: ProvenanceGapKind | null; +} { + return typeof raw === 'string' && DENOMINATOR_KINDS.has(raw) + ? { kind: raw as CachedJudgment['denominatorKind'], gap: null } + : { kind: null, gap: 'invalid-present' }; +} + +/** + * Normalize a raw JSON parse into CachedJudgment (判据②): legacy entries + * written before slice 6c lack window/denominatorKind — surface the gap as + * explicit null instead of leaking `undefined` downstream. + * + * P2 (sol R6): gap classification is by own-property PRESENCE, not value — + * `raw == null` cannot distinguish absent (legacy-missing) from present-null + * (invalid-present; the producer never writes null). Present-but-malformed + * fields fail closed at the read boundary, and non-record raw (e.g. a JSON + * array) is rejected outright — never cast into a full CachedJudgment. + */ +function normalizeCachedJudgment(raw: unknown): CachedJudgment | null { + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return null; + const j = raw as Partial; + const { window, gap: windowGap } = Object.hasOwn(j, 'window') + ? validatePresentWindow(j.window) + : { window: null, gap: 'legacy-missing' as const }; + const { kind: denominatorKind, gap: denominatorGap } = Object.hasOwn(j, 'denominatorKind') + ? validatePresentDenominatorKind(j.denominatorKind) + : { kind: null, gap: 'legacy-missing' as const }; + return { + ...(j as CachedJudgment), + window, + windowGap, + denominatorKind, + denominatorGap, + }; +} + +export class SegmentJudgmentCache { + constructor(private readonly redis: RedisClient) {} + + /** + * Store latest judgments (batch write after eval run). + * Each segment overwrites its previous entry — only latest matters. + */ + async updateBatch(judgments: SegmentJudgment[]): Promise { + if (judgments.length === 0) return; + + const pipeline = this.redis.pipeline(); + for (const j of judgments) { + const cached: CachedJudgment = { + segmentId: j.segmentId, + verdict: j.verdict, + injectionCount: j.evidence.injectionCount.value, + violationCount: j.evidence.violationCount.value, + correlationConfidence: j.evidence.correlationConfidence, + evaluatedAt: j.window.endMs, + runId: j.producedBy.runId, + segmentVersion: j.segmentVersion, + // 判据②: the judgment's OWN eval window + denominator — always present + // on the producer path (SegmentJudgment requires both). + window: { startMs: j.window.startMs, endMs: j.window.endMs }, + windowGap: null, + denominatorKind: j.evidence.denominatorKind, + denominatorGap: null, + }; + pipeline.hset(CACHE_KEY, j.segmentId, JSON.stringify(cached)); + // P1-2: append to per-segment history ZSET (scored by evaluatedAt, permanent) + pipeline.zadd(HISTORY_KEY(j.segmentId), cached.evaluatedAt, JSON.stringify(cached)); + } + await pipeline.exec(); + } + + /** Read cached judgment for a single segment. */ + async get(segmentId: string): Promise { + const raw = await this.redis.hget(CACHE_KEY, segmentId); + if (!raw) return null; + try { + return normalizeCachedJudgment(JSON.parse(raw)); + } catch { + return null; + } + } + + /** Read cached judgments for multiple segments (batch). */ + async getBatch(segmentIds: string[]): Promise> { + if (segmentIds.length === 0) return new Map(); + + const results = new Map(); + const pipeline = this.redis.pipeline(); + for (const id of segmentIds) { + pipeline.hget(CACHE_KEY, id); + } + const replies = await pipeline.exec(); + if (!replies) return results; + + for (let i = 0; i < segmentIds.length; i++) { + const reply = replies[i]; + if (!reply || reply[0]) continue; // error or null + const raw = reply[1] as string | null; + if (!raw) continue; + try { + const normalized = normalizeCachedJudgment(JSON.parse(raw)); + if (normalized) results.set(segmentIds[i], normalized); + } catch { + // skip malformed entries + } + } + return results; + } + + /** + * Read full judgment history for a segment (P1-2: per-version eval). + * Returns all judgments ordered by evaluatedAt (oldest first). + * Limit defaults to 100 — more than enough for any realistic lifetime. + */ + async getHistory(segmentId: string, limit = 100): Promise { + const raws = await this.redis.zrangebyscore(HISTORY_KEY(segmentId), 0, '+inf', 'LIMIT', 0, limit); + const results: CachedJudgment[] = []; + for (const raw of raws) { + try { + const normalized = normalizeCachedJudgment(JSON.parse(raw)); + if (normalized) results.push(normalized); + } catch { + /* skip malformed */ + } + } + return results; + } +} diff --git a/packages/api/src/domains/prompt-hooks/hook-manifest-parser.ts b/packages/api/src/domains/prompt-hooks/hook-manifest-parser.ts index c6d23c7da1..7559850ea0 100644 --- a/packages/api/src/domains/prompt-hooks/hook-manifest-parser.ts +++ b/packages/api/src/domains/prompt-hooks/hook-manifest-parser.ts @@ -6,7 +6,14 @@ */ import { readFileSync } from 'node:fs'; -import type { GovernanceTier, HookManifest, HookStage, SafetyTier, TransparencyTier } from '@cat-cafe/shared'; +import type { + GovernanceTier, + HookManifest, + HookStage, + HookVariableDef, + SafetyTier, + TransparencyTier, +} from '@cat-cafe/shared'; import { parse as parseYaml } from 'yaml'; // --------------------------------------------------------------------------- @@ -89,6 +96,9 @@ export function parseHookManifest(yamlPath: string): HookManifestParseResult { // Inputs array const inputs = requireStringArray(doc, 'inputs', errors); + // Variable definitions (optional, F257 Console 判据⑤) + const variables = requireVariableDefs(doc, 'variables', errors); + // ID format validation if (id && !HOOK_ID_PATTERN.test(id)) { errors.push(`id '${id}' does not match pattern ${HOOK_ID_PATTERN}`); @@ -116,6 +126,7 @@ export function parseHookManifest(yamlPath: string): HookManifestParseResult { template: template as string, resolver, inputs: inputs as string[], + variables: variables as HookVariableDef[] | undefined, disableable: disableable as boolean, safetyTier: safetyTier as SafetyTier, transparencyTier: transparencyTier as TransparencyTier, @@ -182,3 +193,51 @@ function requireStringArray(doc: Record, field: string, errors: } return val as string[]; } + +function requireVariableDefs( + doc: Record, + field: string, + errors: string[], +): HookVariableDef[] | undefined { + const val = doc[field]; + if (val === undefined) return undefined; + if (!Array.isArray(val)) { + errors.push(`'${field}' must be an array`); + return undefined; + } + + const result: HookVariableDef[] = []; + for (let i = 0; i < val.length; i++) { + const item = val[i]; + if (typeof item !== 'object' || item === null || Array.isArray(item)) { + errors.push(`'${field}[${i}]' must be an object`); + continue; + } + const entry = item as Record; + const name = entry.name; + if (typeof name !== 'string' || name.length === 0) { + errors.push(`'${field}[${i}].name' must be a non-empty string`); + continue; + } + const def: HookVariableDef = { name }; + if (entry.description !== undefined) { + if (typeof entry.description !== 'string') { + errors.push(`'${field}[${i}].description' must be a string`); + continue; + } + def.description = entry.description; + } + if (entry.placeholder !== undefined) { + if (typeof entry.placeholder !== 'string') { + errors.push(`'${field}[${i}].placeholder' must be a string`); + continue; + } + def.placeholder = entry.placeholder; + } + result.push(def); + } + + // If any item failed validation, return undefined so the manifest is rejected. + if (errors.length > 0) return undefined; + return result; +} diff --git a/packages/api/src/domains/prompt-hooks/hook-override-event-recorder.ts b/packages/api/src/domains/prompt-hooks/hook-override-event-recorder.ts new file mode 100644 index 0000000000..3e8f6984a0 --- /dev/null +++ b/packages/api/src/domains/prompt-hooks/hook-override-event-recorder.ts @@ -0,0 +1,114 @@ +/** + * Event recording and listing for HookOverrideStore. + * Extracted to keep HookOverrideStore under the 350-line limit. + */ + +import type { HookOverride, HookOverrideSource, OverrideAction, OverrideChangeEvent } from '@cat-cafe/shared'; +import type { RedisClient } from '@cat-cafe/shared/utils'; + +// --------------------------------------------------------------------------- +// Redis key helpers (shared with HookOverrideStore) +// --------------------------------------------------------------------------- + +export const EVENT_ZSET = (ws: string) => `hook-override-events:${ws}`; +export const EVENT_KEY = (ws: string, id: string) => `hook-override-event:${ws}:${id}`; + +// --------------------------------------------------------------------------- +// Event recorder +// --------------------------------------------------------------------------- + +export class HookOverrideEventRecorder { + /** Monotonic counter for event ID uniqueness within a process. */ + private eventSeq = 0; + + constructor(private readonly redis: RedisClient) {} + + async record( + workspaceId: string, + hookId: string, + action: OverrideAction, + source: HookOverrideSource, + actorId: string, + reason?: string, + contentVersion?: number, + epochVersion?: number, + ): Promise { + const timestamp = Date.now(); + const seq = this.eventSeq++; + const eventId = `${timestamp}-${String(seq).padStart(6, '0')}-${hookId}-${action}`; + const event: OverrideChangeEvent = { + eventId, + hookId, + workspaceId, + action, + source, + timestamp, + actorId, + ...(reason ? { reason } : {}), + ...(contentVersion != null ? { contentVersion } : {}), + ...(epochVersion != null ? { epochVersion } : {}), + }; + // TTL=0: audit events are permanent (Iron Law 5, sol P1-2 fix) + await this.redis.set(EVENT_KEY(workspaceId, eventId), JSON.stringify(event)); + await this.redis.zadd(EVENT_ZSET(workspaceId), timestamp, eventId); + } + + async list( + workspaceId: string, + opts?: { limit?: number; since?: number; until?: number }, + ): Promise { + const since = opts?.since ?? 0; + const until = opts?.until ?? '+inf'; + const limit = opts?.limit ?? 50; + const eventIds = await this.redis.zrangebyscore(EVENT_ZSET(workspaceId), since, until, 'LIMIT', 0, limit); + const events: OverrideChangeEvent[] = []; + for (const id of eventIds) { + const raw = await this.redis.get(EVENT_KEY(workspaceId, id)); + if (!raw) continue; + try { + events.push(JSON.parse(raw) as OverrideChangeEvent); + } catch { + /* skip */ + } + } + return events; + } +} + +// --------------------------------------------------------------------------- +// Reconciliation (pure function — decoupled from store class) +// --------------------------------------------------------------------------- + +export type ManifestLookupFn = (hookId: string) => { disableable?: boolean; safetyTier?: string } | undefined; + +/** + * Reconcile a single override against current manifest constraints. + * Returns sanitized override, or null if the hook is no longer in the registry. + */ +export function reconcileOverride(override: HookOverride, manifestLookup: ManifestLookupFn): HookOverride | null { + const manifest = manifestLookup(override.hookId); + if (!manifest) return null; + + let sanitized = override; + + if (sanitized.enabled === false && !manifest.disableable) { + const { enabled: _, ...rest } = sanitized; + sanitized = rest as HookOverride; + } + + if (sanitized.contentOverride !== undefined && manifest.safetyTier === 'readonly') { + const { contentOverride: _, contentVersion: __, contentSource: _cs, ...rest } = sanitized; + sanitized = rest as HookOverride; + } + + if ( + sanitized.contentOverride !== undefined && + manifest.safetyTier === 'limited-edit' && + sanitized.contentSource !== 'operator' + ) { + const { contentOverride: _, contentVersion: __, contentSource: _cs, ...rest } = sanitized; + sanitized = rest as HookOverride; + } + + return sanitized; +} diff --git a/packages/api/src/domains/prompt-hooks/l0-manifest-trace.ts b/packages/api/src/domains/prompt-hooks/l0-manifest-trace.ts new file mode 100644 index 0000000000..8b566f5afd --- /dev/null +++ b/packages/api/src/domains/prompt-hooks/l0-manifest-trace.ts @@ -0,0 +1,89 @@ +/** + * F257 #2 — L0 manifest → session trace adapter. + * + * Converts the per-segment L1-L7 manifest emitted by the ACTUAL L0 compiler + * (`getL0ManifestViaSubprocess`) into a session `PipelineResult`, so the existing + * trace bridge (`buildFromPipeline` → `eventsToSegments`) persists it as per-segment + * `ObservedSegment`s — no second persistence format. + * + * Why this and not the (rejected) `collectNativeL0SessionTrace`: that reran the API + * hook pipeline (a separate code path) and could report OVERRIDDEN L content the + * override-blind native compiler never delivered. This adapter's input IS the compiled + * artifact, so hash/char/token describe exactly what the provider received. Version is + * the only field not in the artifact; it resolves from the hook registry (same source + * the segment lifeline uses), defaulting to 1 for the always-on L hooks. + */ + +import type { TraceEvent, TraceEventFired } from '@cat-cafe/shared'; +import { estimateTokens } from '../../utils/token-counter.js'; +import type { L0SegmentContent } from '../cats/services/agents/providers/l0-compiler.js'; +import type { PipelineResult } from './HookPipeline.js'; +import { getCachedRegistry } from './PipelinePromptBuilder.js'; +import { hashContent } from './trace-collector.js'; + +/** The native L0 identity is exactly these segments, in this order (compiler-emitted). */ +const CANONICAL_L_SEGMENTS = ['L1', 'L2', 'L3', 'L4', 'L5', 'L6', 'L7'] as const; + +/** + * F257 #2 (2b R2 P1-1): validate the manifest as ONE atomic L1-L7 artifact. + * Returns null when valid, else a human-readable reason. + * + * The native L0 identity is delivered as a whole, so the trace must trust it atomically: + * a partial / empty / reordered / foreign / duplicate / blank-content manifest means the + * producer (compiler / CLI) regressed, and recording it as healthy `fired` data would + * recreate the original incident (Console shows an apparently-injected iron-law segment + * that was actually dropped or empty). Any violation → reject the WHOLE manifest into the + * visible producer-failure path, never a partial success. + */ +export function validateL0Manifest(manifest: readonly L0SegmentContent[]): string | null { + if (manifest.length !== CANONICAL_L_SEGMENTS.length) { + return `expected exactly ${CANONICAL_L_SEGMENTS.length} L segments, got ${manifest.length}`; + } + for (let i = 0; i < CANONICAL_L_SEGMENTS.length; i++) { + const seg = manifest[i]; + if (!seg || seg.segmentId !== CANONICAL_L_SEGMENTS[i]) { + // Catches missing / duplicate / foreign / reordered in one canonical-order check. + return `segment[${i}] must be ${CANONICAL_L_SEGMENTS[i]}, got "${seg?.segmentId}"`; + } + if (typeof seg.content !== 'string' || seg.content.trim().length === 0) { + return `${seg.segmentId} has blank content`; + } + } + return null; +} + +/** + * Build a session-stage `PipelineResult` from the real L0 compiler manifest, or null when + * the manifest fails atomic validation (see validateL0Manifest) — callers then emit a + * visible "L not observed" signal instead of persisting a partial/false healthy trace. + */ +export function l0ManifestToSessionResult(manifest: readonly L0SegmentContent[]): PipelineResult | null { + if (validateL0Manifest(manifest) !== null) return null; + const registry = getCachedRegistry(); + const timestamp = Date.now(); + + const patches = manifest.map((seg, i) => ({ + hookId: seg.segmentId, + content: seg.content, + order: (i + 1) * 100, + })); + + const events: TraceEvent[] = manifest.map( + (seg): TraceEventFired => ({ + hookId: seg.segmentId, + stage: 'session-init', + timestamp, + status: 'fired', + version: registry?.getHook(seg.segmentId)?.manifest.version ?? 1, + contentHash: hashContent(seg.content), + tokenEstimate: estimateTokens(seg.content), + // F257 Console 判据④:native L0 content IS the actual rendered artifact. + content: seg.content, + contentSourceKind: 'native-l0', + templateRef: seg.segmentId, + templateVars: null, + }), + ); + + return { patches, events }; +} diff --git a/packages/api/src/domains/prompt-hooks/native-l0-trace.ts b/packages/api/src/domains/prompt-hooks/native-l0-trace.ts new file mode 100644 index 0000000000..e05023d0fc --- /dev/null +++ b/packages/api/src/domains/prompt-hooks/native-l0-trace.ts @@ -0,0 +1,89 @@ +/** + * F257 #2 — native-L0 session trace persistence (shared by route-serial + route-parallel). + * + * Persists the L1-L7 session trace from the ACTUAL L0 compiler manifest + * (`getL0ManifestViaSubprocess`), bridged through the existing `buildFromPipeline`. + * + * Fully fire-and-forget: call WITHOUT awaiting so it never taxes the model critical + * path (sol 2b R1 P2-1). The manifest is cache-first; a cold cache shares the provider's + * own compile via the l0-compiler in-flight dedup — no redundant full-stage run. An empty + * manifest emits a visible producer warning rather than silently persisting D-only, so + * "L 系列无数据" is distinguishable from a healthy zero. + * + * Centralizing here (vs. inlining in two large route functions) is also sol 2b R1 P2-2: + * one producer seam, unit-testable without driving a whole route. + */ + +import type { ReplayProvenanceGap } from '@cat-cafe/shared'; +import { getL0ManifestViaSubprocess } from '../cats/services/agents/providers/l0-compiler.js'; +import type { IMessageStore } from '../cats/services/stores/ports/MessageStore.js'; +import type { PipelineResult } from './HookPipeline.js'; +import type { InjectionTraceStore } from './InjectionTraceStore.js'; +import { l0ManifestToSessionResult, validateL0Manifest } from './l0-manifest-trace.js'; +import { buildFromPipeline, buildReplaySnapshots, captureSurroundingMessageIds } from './trace-bridge.js'; + +interface TraceLogger { + warn(obj: Record, msg: string): void; +} + +export interface PersistNativeL0Params { + traceStore: InjectionTraceStore; + catId: string; + threadId: string; + turnId: string; + /** The already-drained per-turn (D-series) pipeline trace for this invocation. */ + turnResult: PipelineResult | null; + log: TraceLogger; + /** F257 Console 判据④:owner-scoped replay snapshot context. */ + ownerUserId: string; + messageAnchorId: string | null; + messageStore?: IMessageStore; +} + +export async function persistNativeL0SessionTrace(params: PersistNativeL0Params): Promise { + const { traceStore, catId, threadId, turnId, turnResult, log, ownerUserId, messageAnchorId, messageStore } = params; + try { + const manifest = await getL0ManifestViaSubprocess({ catId }); + // 2b R2 P1-1: reject the manifest atomically. A partial/foreign/blank/reordered manifest + // is a producer regression — surface WHY (visible signal), never persist a partial success. + const rejectReason = validateL0Manifest(manifest); + if (rejectReason) { + log.warn( + { catId, threadId, reason: rejectReason }, + '[F257] native L0 manifest rejected — L1-L7 not observed this turn (producer signal)', + ); + } + const sessionResult = l0ManifestToSessionResult(manifest); // null iff rejectReason + const bridge = buildFromPipeline(sessionResult, turnResult, { + turnId, + threadId, + catId, + hasNativeL0: true, + sessionFromNativeCompiler: sessionResult !== null, + }); + if (bridge) { + await traceStore.persist(bridge.summary, bridge.detail); + // Capture event-time context inside the fire-and-forget persistence path so it does not + // block the model critical path (sol R3 P2). + const surroundingCapture = messageStore + ? await captureSurroundingMessageIds(messageStore, threadId, messageAnchorId, ownerUserId) + : { ids: [], gap: 'unavailable' as ReplayProvenanceGap }; + const snapshots = buildReplaySnapshots(sessionResult, turnResult, { + threadId, + turnId, + catId, + timestamp: bridge.detail.timestamp, + ownerUserId, + messageAnchorId, + surroundingMessageIds: surroundingCapture.ids, + surroundingMessagesGap: surroundingCapture.gap, + }); + await traceStore.persistReplaySnapshots(threadId, turnId, snapshots); + } + } catch (err) { + log.warn( + { err: err instanceof Error ? err.message : String(err), catId, threadId }, + '[F257] native L0 session trace failed (fire-and-forget)', + ); + } +} diff --git a/packages/api/src/domains/prompt-hooks/trace-bridge.ts b/packages/api/src/domains/prompt-hooks/trace-bridge.ts new file mode 100644 index 0000000000..b7a5e2c62e --- /dev/null +++ b/packages/api/src/domains/prompt-hooks/trace-bridge.ts @@ -0,0 +1,374 @@ +/** + * F257 Trace Persistence Bridge — Phase A Line B + * + * Adapts pipeline-produced PipelineResult (per-hook TraceEvent[]) + * into v0 InjectionTraceSummary / InjectionTraceDetail formats + * consumed by InjectionTraceStore. + * + * Replaces the redundant v0 collectTrace() path which re-invoked + * buildStaticIdentity(annotateSegments: true) on every turn. + * The pipeline already produces richer per-hook data at drain time; + * this bridge converts it to the v0 persistence format. + * + * When pipeline traces are unavailable (e.g., legacy path or native + * L0 without pipeline), callers fall back to the existing v0 path. + */ + +import { createHash } from 'node:crypto'; +import type { + DeliveryChannel, + InjectionTraceDetail, + InjectionTraceSummary, + ObservedSegment, + ReplayProvenanceGap, + ReplaySnapshot, + SegmentContentSourceKind, + StageDeliveryDecision, + TraceEventFired, +} from '@cat-cafe/shared'; +import type { IMessageStore } from '../cats/services/stores/ports/MessageStore.js'; +import type { PipelineResult } from './HookPipeline.js'; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export interface TraceBridgeMeta { + turnId: string; + sessionId?: string; + threadId: string; + catId: string; + hasNativeL0: boolean; + /** + * F257 #2: the session result is the native L0 compiler's L1-L7 manifest (delivered + * via `--system-prompt-file` / native carrier), so the session-stage delivery channel + * is `native-l0`, not `pack-only` (which stays correct for actual pack blocks). + */ + sessionFromNativeCompiler?: boolean; +} + +const SURROUNDING_MESSAGE_LIMIT = 20; + +export interface SurroundingMessageCapture { + ids: string[]; + gap: ReplayProvenanceGap | null; +} + +/** + * Capture the message IDs that constitute the event-time conversation context. + * + * Returns the anchor message (incoming user/A2A trigger) plus the messages that + * preceded it in the thread. Future messages are excluded by construction because + * they do not exist at persistence time. Failures are surfaced as structured gaps + * instead of being silently folded into an empty "complete" list. + */ +export async function captureSurroundingMessageIds( + messageStore: IMessageStore | undefined, + threadId: string, + messageAnchorId: string | null, + userId: string, +): Promise { + if (!messageStore) return { ids: [], gap: 'unavailable' }; + if (!messageAnchorId) return { ids: [], gap: 'legacy-missing' }; + try { + const anchor = await messageStore.getById(messageAnchorId); + if (!anchor) return { ids: [], gap: 'legacy-missing' }; + if (anchor.threadId !== threadId) return { ids: [], gap: 'invalid-present' }; + const before = await messageStore.getByThreadBefore( + threadId, + anchor.timestamp, + SURROUNDING_MESSAGE_LIMIT - 1, + anchor.id, + userId, + ); + return { ids: [...before.map((m) => m.id), anchor.id], gap: null }; + } catch { + return { ids: [], gap: 'unavailable' }; + } +} + +/** + * Build v0 InjectionTraceSummary + InjectionTraceDetail from pipeline results. + * + * Returns null when both session and turn results are null (no pipeline + * traces captured — caller should fall back to v0 collectTrace path). + */ +export function buildFromPipeline( + sessionResult: PipelineResult | null, + turnResult: PipelineResult | null, + meta: TraceBridgeMeta, +): { summary: InjectionTraceSummary; detail: InjectionTraceDetail } | null { + if (!sessionResult && !turnResult) return null; + + const sessionSegments = sessionResult ? eventsToSegments(sessionResult, 'session-init') : []; + const turnSegments = turnResult ? eventsToSegments(turnResult, 'per-turn') : []; + const allSegments = [...sessionSegments, ...turnSegments]; + + const observed = allSegments.filter((s) => s.status === 'observed'); + const absent = allSegments.filter((s) => s.status === 'absent'); + + const sessionTokens = sumTokens(sessionSegments); + const turnTokens = sumTokens(turnSegments); + const sessionChars = sumChars(sessionResult); + const turnChars = sumChars(turnResult); + + const delivery = buildDelivery(sessionResult, turnResult, meta.hasNativeL0, meta.sessionFromNativeCompiler ?? false); + const timestamp = Date.now(); + + // F257 Console 判据④ R2: summary is compact — no full content/templateVars. + // Full event-time content lives in durable ReplaySnapshot (TTL=0, owner-scoped). + const compactSegments = allSegments.map(toCompactSegment); + + const summary: InjectionTraceSummary = { + turnId: meta.turnId, + ...(meta.sessionId ? { sessionId: meta.sessionId } : {}), + threadId: meta.threadId, + catId: meta.catId, + timestamp, + segments: compactSegments, + delivery, + totalCharCount: sessionChars + turnChars, + totalTokenEstimate: sessionTokens + turnTokens, + totalSegmentsObserved: observed.length, + totalSegmentsAbsent: absent.length, + durationMs: 0, // Pipeline doesn't track duration; 0 = not measured + }; + + const detail: InjectionTraceDetail = { + turnId: meta.turnId, + threadId: meta.threadId, + catId: meta.catId, + timestamp, + sessionContentHash: assembledContentHash(sessionResult), + turnContentHash: assembledContentHash(turnResult), + sessionCharCount: sessionChars, + sessionTokenEstimate: sessionTokens, + turnCharCount: turnChars, + turnTokenEstimate: turnTokens, + segments: allSegments, + }; + + return { summary, detail }; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +type InjectionStage = 'session-init' | 'per-turn'; + +/** + * Convert pipeline TraceEvent[] to ObservedSegment[] with pipeline-rich fields. + * + * P1 fix (codex review 629795f29): preserves version, pipelineStatus, + * reasonCode/reason (skipped), disabledBy (disabled) — the evidence tuple + * F257 needs: (hookId, version, fired/skipped + reason, token). + */ +function eventsToSegments(result: PipelineResult, stage: InjectionStage): ObservedSegment[] { + const patchMap = new Map(result.patches.map((p) => [p.hookId, p])); + return result.events.map((ev): ObservedSegment => { + if (ev.status === 'fired') { + const patch = patchMap.get(ev.hookId); + return { + segmentId: ev.hookId, + stage, + status: 'observed', + contentHash: ev.contentHash, + charCount: patch?.content.length ?? 0, + tokenEstimate: ev.tokenEstimate, + // F257 pipeline-rich fields + version: ev.version, + pipelineStatus: 'fired', + // F257 Console 判据④:event-time rendered content + source provenance. + content: ev.content ?? patch?.content ?? null, + contentSourceKind: ev.contentSourceKind ?? (patch ? 'template' : null), + templateRef: ev.templateRef ?? null, + templateVars: ev.templateVars ?? null, + }; + } + if (ev.status === 'skipped') { + return { + segmentId: ev.hookId, + stage, + status: 'absent', + contentHash: null, + charCount: 0, + tokenEstimate: 0, + pipelineStatus: 'skipped', + reasonCode: ev.reasonCode, + reason: ev.reason, + }; + } + if (ev.status === 'disabled') { + return { + segmentId: ev.hookId, + stage, + status: 'absent', + contentHash: null, + charCount: 0, + tokenEstimate: 0, + pipelineStatus: 'disabled', + disabledBy: ev.disabledBy, + }; + } + // 'observed' status (observed-without-content) + return { + segmentId: ev.hookId, + stage, + status: 'observed', + contentHash: 'contentHash' in ev ? ev.contentHash : null, + charCount: 0, + tokenEstimate: 'tokenEstimate' in ev ? ev.tokenEstimate : 0, + pipelineStatus: 'observed', + }; + }); +} + +function sumTokens(segments: ObservedSegment[]): number { + return segments.reduce((acc, s) => acc + s.tokenEstimate, 0); +} + +/** + * F257 Console 判据④ R2: strip full content and variable bindings from summary segments. + * The compact summary keeps only counts, hashes, version and pipeline status. + * Replay content is fetched from durable ReplaySnapshot. + */ +function toCompactSegment(segment: ObservedSegment): ObservedSegment { + const compact: ObservedSegment = { + segmentId: segment.segmentId, + stage: segment.stage, + status: segment.status, + contentHash: segment.contentHash, + charCount: segment.charCount, + tokenEstimate: segment.tokenEstimate, + }; + if (segment.version !== undefined) compact.version = segment.version; + if (segment.pipelineStatus !== undefined) compact.pipelineStatus = segment.pipelineStatus; + if (segment.reasonCode !== undefined) compact.reasonCode = segment.reasonCode; + if (segment.reason !== undefined) compact.reason = segment.reason; + if (segment.disabledBy !== undefined) compact.disabledBy = segment.disabledBy; + return compact; +} + +function sumChars(result: PipelineResult | null): number { + if (!result) return 0; + return result.patches.reduce((acc, p) => acc + p.content.length, 0); +} + +/** + * Hash assembled patch content matching HookPipeline.assemblePatches semantics: + * patches in original order (manifest order), joined with '\n\n'. + * + * P1 fix (codex review 629795f29): firstFiredHash used only first hook's hash. + * P2 fix (codex re-review 84ea1785d): hookId sort + empty join diverged from + * actual assembly order/separator — hash must match what the model receives. + */ +function assembledContentHash(result: PipelineResult | null): string | null { + if (!result || result.patches.length === 0) return null; + // Patches are already in manifest order from HookPipeline.executeStage. + // Replicate HookPipeline.assemblePatches join semantics exactly. + const combined = result.patches.map((p) => p.content).join('\n\n'); + return createHash('sha256').update(combined).digest('hex').slice(0, 16); +} + +/** + * Build durable ReplaySnapshot records for every fired segment in the pipeline result. + * + * The caller supplies event-time conversation anchors (messageAnchorId + + * surroundingMessageIds) obtained from the message store at persistence time, + * so the snapshot is immutable wrt future thread writes. + */ +export function buildReplaySnapshots( + sessionResult: PipelineResult | null, + turnResult: PipelineResult | null, + meta: { + threadId: string; + turnId: string; + catId: string; + timestamp: number; + ownerUserId: string; + messageAnchorId: string | null; + surroundingMessageIds: string[]; + surroundingMessagesGap: ReplayProvenanceGap | null; + }, +): ReplaySnapshot[] { + const sessionSnapshots = sessionResult ? eventsToSnapshots(sessionResult, 'session-init', meta) : []; + const turnSnapshots = turnResult ? eventsToSnapshots(turnResult, 'per-turn', meta) : []; + return [...sessionSnapshots, ...turnSnapshots]; +} + +function eventsToSnapshots( + result: PipelineResult, + stage: InjectionStage, + meta: { + threadId: string; + turnId: string; + catId: string; + timestamp: number; + ownerUserId: string; + messageAnchorId: string | null; + surroundingMessageIds: string[]; + surroundingMessagesGap: ReplayProvenanceGap | null; + }, +): ReplaySnapshot[] { + const patchMap = new Map(result.patches.map((p) => [p.hookId, p])); + return result.events + .filter((ev): ev is TraceEventFired => ev.status === 'fired') + .map((ev) => { + const patch = patchMap.get(ev.hookId); + const content = ev.content ?? patch?.content ?? null; + const sourceKind: SegmentContentSourceKind = ev.contentSourceKind ?? (patch ? 'template' : null); + return { + segmentId: ev.hookId, + threadId: meta.threadId, + turnId: meta.turnId, + timestamp: meta.timestamp, + catId: meta.catId, + stage, + pipelineStatus: 'fired', + version: ev.version ?? null, + content, + contentSourceKind: sourceKind, + contentSourceRef: ev.templateRef ?? patch?.hookId ?? null, + templateVars: ev.templateVars ?? null, + messageAnchorId: meta.messageAnchorId, + surroundingMessageIds: meta.surroundingMessageIds, + surroundingMessagesGap: meta.surroundingMessagesGap, + ownerUserId: meta.ownerUserId, + }; + }); +} + +function buildDelivery( + sessionResult: PipelineResult | null, + turnResult: PipelineResult | null, + hasNativeL0: boolean, + sessionFromNativeCompiler: boolean, +): StageDeliveryDecision[] { + // F257 #2: L1-L7 sourced from the native compiler manifest → 'native-l0'. Only the + // pack-blocks path (no compiler manifest) stays 'pack-only'. + const sessionChannel: DeliveryChannel = sessionFromNativeCompiler + ? 'native-l0' + : hasNativeL0 + ? 'pack-only' + : 'message-prepend'; + const sessionReason = sessionFromNativeCompiler + ? 'Pipeline bridge: L1-L7 delivered via native L0 compiler artifact' + : hasNativeL0 + ? 'Pipeline bridge: pack-only for native L0' + : 'Pipeline bridge: content assembled for message-prepend'; + return [ + { + stage: 'session-init' as InjectionStage, + contentAssembled: sessionResult !== null && sessionResult.patches.length > 0, + channel: sessionChannel, + reason: sessionReason, + }, + { + stage: 'per-turn' as InjectionStage, + contentAssembled: turnResult !== null && turnResult.patches.length > 0, + channel: 'message-prepend', + reason: 'Pipeline bridge: per-turn content assembled', + }, + ]; +} diff --git a/packages/api/src/domains/prompt-hooks/trace-collector.ts b/packages/api/src/domains/prompt-hooks/trace-collector.ts index f89e51a3be..9ef35eb8e0 100644 --- a/packages/api/src/domains/prompt-hooks/trace-collector.ts +++ b/packages/api/src/domains/prompt-hooks/trace-collector.ts @@ -55,6 +55,11 @@ export function parseAnnotatedSegments(annotated: string, stage: InjectionStage) contentHash: content.length > 0 ? hashContent(content) : null, charCount: content.length, tokenEstimate: content.length > 0 ? estimateTokens(content) : 0, + // F257 Console 判据④:v0 collector can回填 content; source is aggregate (legacy path). + content: content.length > 0 ? content : null, + contentSourceKind: content.length > 0 ? 'aggregate' : null, + templateRef: null, + templateVars: null, }); } @@ -110,6 +115,10 @@ export function collectTrace( contentHash: hashContent(sessionContent), charCount: sessionContent.length, tokenEstimate: estimateTokens(sessionContent), + content: sessionContent, + contentSourceKind: 'aggregate', + templateRef: null, + templateVars: null, }, ]; } @@ -124,6 +133,10 @@ export function collectTrace( contentHash: hashContent(sessionContent), charCount: sessionContent.length, tokenEstimate: estimateTokens(sessionContent), + content: sessionContent, + contentSourceKind: 'aggregate', + templateRef: null, + templateVars: null, }, ]; } @@ -139,6 +152,10 @@ export function collectTrace( contentHash: hashContent(turnContent), charCount: turnContent.length, tokenEstimate: estimateTokens(turnContent), + content: turnContent, + contentSourceKind: 'aggregate', + templateRef: null, + templateVars: null, }, ] : []; diff --git a/packages/api/src/domains/signals/services/podcast-generator.ts b/packages/api/src/domains/signals/services/podcast-generator.ts index 10c6760271..b8e67c3bec 100644 --- a/packages/api/src/domains/signals/services/podcast-generator.ts +++ b/packages/api/src/domains/signals/services/podcast-generator.ts @@ -144,6 +144,7 @@ export async function generateScriptViaThread( // ① Write user message into thread const userMsg = await deps.messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, // sol R3 P1-1 threadId, catId: null, content: prompt, diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 5fb9f9238c..decf2b7053 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -3,7 +3,8 @@ * 后端 API 入口 */ -import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { join, resolve } from 'node:path'; import { type CatConfig, type CatId, @@ -67,6 +68,7 @@ import { warmL0Cache, } from './domains/cats/services/agents/providers/l0-compiler.js'; import { AgentRegistry } from './domains/cats/services/agents/registry/AgentRegistry.js'; +import { analyzeA2AMentions } from './domains/cats/services/agents/routing/a2a-mentions.js'; import { AuthorizationManager } from './domains/cats/services/auth/AuthorizationManager.js'; import { createFreshnessReinvokeCheck } from './domains/cats/services/freshness/createFreshnessReinvokeCheck.js'; import { FreshnessInvocationStateStore } from './domains/cats/services/freshness/FreshnessInvocationStateStore.js'; @@ -119,8 +121,10 @@ import { createSummaryStore } from './domains/cats/services/stores/factories/Sum import { createTaskStore } from './domains/cats/services/stores/factories/TaskStoreFactory.js'; import { createThreadStore } from './domains/cats/services/stores/factories/ThreadStoreFactory.js'; import { createWorkflowSopStore } from './domains/cats/services/stores/factories/WorkflowSopStoreFactory.js'; +import { routedProvenance } from './domains/cats/services/stores/ports/MessageStore.js'; import { RedisInvocationRecordStore } from './domains/cats/services/stores/redis/RedisInvocationRecordStore.js'; import { RedisMessageStore } from './domains/cats/services/stores/redis/RedisMessageStore.js'; +import { RedisRoutingFactProjection } from './domains/cats/services/stores/redis/RedisRoutingFactProjection.js'; import { MlxAudioTtsProvider } from './domains/cats/services/tts/MlxAudioTtsProvider.js'; import { initStreamingTtsRegistry } from './domains/cats/services/tts/StreamingTtsChunker.js'; import { TtsRegistry } from './domains/cats/services/tts/TtsRegistry.js'; @@ -161,6 +165,7 @@ import { fetchLatestIssueCommentCursor, } from './infrastructure/github/comment-cursors.js'; import { buildGhCliEnv, resolveGhCliToken, withHiddenGhCliWindow } from './infrastructure/github/gh-cli-env.js'; +import { RedisDeviationEventLog } from './infrastructure/harness-eval/deviation/DeviationEventLog.js'; import type { EvalDomainId } from './infrastructure/harness-eval/domain/eval-domain-registry.js'; import { runSchedulerReplyUserIdBackfill } from './infrastructure/scheduler/scheduler-reply-userid-backfill.js'; import { securityHeadersPlugin } from './infrastructure/security-headers.js'; @@ -567,11 +572,31 @@ async function main(): Promise { // F102 KD-34: append listener placeholder (wired after memoryServices init) let appendListener: ((msg: { id: string; threadId: string; timestamp: number; content: string }) => void) | null = null; - + let hardDeleteListener: ((msg: { id: string; threadId: string; userId: string }) => void) | null = null; + let deleteByThreadListener: ((threadId: string) => void) | null = null; + let deleteMagicWordRefsByEventIds: ((eventIds: readonly string[]) => void) | null = null; + let deleteMagicWordRefsByThread: ((threadId: string) => void) | null = null; + + // F257 V1: RoutingDecisionFact query projection (§4.5.1) — derived async from + // the authority field embedded in message hashes; reconcile-before-evaluate + // repairs any gap, so this worker is a cache warmer, not a truth source. + const routingFactProjection = redis ? new RedisRoutingFactProjection(redis) : undefined; + // F257 V1: deviation ledger (§3.1 存储规格) — manual_observation write branch + // lands via cat_cafe_report_harness_signal (T-C §3.6). + const deviationEventLog = redis ? new RedisDeviationEventLog(redis) : undefined; const messageStore = createMessageStore(redis, { onAppend: (msg) => { appendListener?.(msg); }, + onBeforeHardDelete: (msg) => { + if (!hardDeleteListener) throw new Error('message hard-delete fence not initialized'); + hardDeleteListener(msg); + }, + onBeforeDeleteByThread: (threadId) => { + if (!deleteByThreadListener) throw new Error('message thread-delete fence not initialized'); + deleteByThreadListener(threadId); + }, + ...(routingFactProjection ? { routingFactProjection } : {}), }); const sessionStore = redis ? new SessionStore(redis) : undefined; const deliveryCursorStore = new DeliveryCursorStore(sessionStore); @@ -842,6 +867,19 @@ async function main(): Promise { return excluded; }, }); + // F257 R9: persisted fences are the deletion linearization point across + // Redis message authority, Event Memory/dead-letter, and episode refs. + hardDeleteListener = (msg) => { + if (!deleteMagicWordRefsByEventIds) throw new Error('magic-word ref deletion fence not initialized'); + const eventIds = memoryServices.eventMemoryStore.getByCoord(msg.threadId, msg.id).map((event) => event.eventId); + deleteMagicWordRefsByEventIds(eventIds); + memoryServices.eventMemoryStore.deleteByCoord(msg.threadId, msg.id); + }; + deleteByThreadListener = (threadId) => { + if (!deleteMagicWordRefsByThread) throw new Error('magic-word thread deletion fence not initialized'); + deleteMagicWordRefsByThread(threadId); + memoryServices.eventMemoryStore.deleteByThread(threadId); + }; // F152: Wire evidence store into /ready probe evidenceStoreRef = memoryServices.evidenceStore; app.log.info('[api] F102: SQLite memory services initialized'); @@ -1553,6 +1591,7 @@ async function main(): Promise { // The `content` field (from buildFallbackMessageContent) already // identifies which cat the failure is about. await messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, // sol R3 P1-1 threadId: fbThreadId, userId: 'system', content, @@ -1604,6 +1643,45 @@ async function main(): Promise { const { InjectionTraceStore: _ITSEarly } = await import('./domains/prompt-hooks/InjectionTraceStore.js'); const injectionTraceStore = redis ? new _ITSEarly(redis) : undefined; + // F257 Phase A (Line B): GuardRejectionEventLog — guard rejection observation layer. + // Fail-open: observation never blocks business. Used by emit points (hold_ball 429, + // A2A block_pingpong) and consumed by eval:harness-ledger domain. + let guardRejectionLog: + | import('./infrastructure/harness-eval/GuardRejectionEventLog.js').GuardRejectionEventLog + | undefined; + if (redis) { + const { GuardRejectionEventLog } = await import('./infrastructure/harness-eval/GuardRejectionEventLog.js'); + guardRejectionLog = new GuardRejectionEventLog(redis); + } + + // F237 PR3: HookOverrideStore — per-workspace runtime override layer. + // Wire into PipelinePromptBuilder singleton so refreshOverrideSnapshot() + // can load overrides before synchronous pipeline execution. + // ManifestLookup is a lazy closure over getCachedRegistry — the registry + // may not exist at bootstrap time (lazy-init on first pipeline call), + // but will always be available when write methods are actually invoked. + // F257 approval executor: capture the instance so the operator-gated + // override routes share the SAME store (single write path, one event stream). + let hookOverrideStore: import('./domains/prompt-hooks/HookOverrideStore.js').HookOverrideStore | undefined; + // F257 Phase D: judgment cache for persisting latest per-segment eval results. + let segmentJudgmentCache: import('./domains/prompt-hooks/SegmentJudgmentCache.js').SegmentJudgmentCache | undefined; + if (redis) { + const { HookOverrideStore } = await import('./domains/prompt-hooks/HookOverrideStore.js'); + const { setOverrideStore, getCachedRegistry, refreshOverrideSnapshot } = await import( + './domains/prompt-hooks/PipelinePromptBuilder.js' + ); + const manifestLookup = (hookId: string) => getCachedRegistry()?.getHook(hookId)?.manifest; + hookOverrideStore = new HookOverrideStore(redis, manifestLookup); + setOverrideStore(hookOverrideStore); + // AF-1: pre-warm registry + override snapshot at bootstrap so cold-start + // requests don't hit null getCachedRegistry(). refreshOverrideSnapshot() + // triggers getPipeline() internally if registry is uninitialized. + await refreshOverrideSnapshot(); + // F257 Phase D: judgment cache shares the same Redis client. + const { SegmentJudgmentCache } = await import('./domains/prompt-hooks/SegmentJudgmentCache.js'); + segmentJudgmentCache = new SegmentJudgmentCache(redis); + } + // Shared AgentRouter — used by messagesRoutes and invocationsRoutes router = new AgentRouter({ agentRegistry, @@ -1645,6 +1723,7 @@ async function main(): Promise { ...(freshnessReinvokeCheck ? { freshnessReinvokeCheck } : {}), ...(freshnessStateStore ? { freshnessStateStore } : {}), ...(injectionTraceStore ? { injectionTraceStore } : {}), + ...(guardRejectionLog ? { guardRejectionLog } : {}), }); // F39: Message queue delivery @@ -1908,9 +1987,9 @@ async function main(): Promise { }; const { evalHubRoutes } = await import('./routes/eval-hub.js'); - // F192 Phase H AC-H4: real GitPublisher (git worktree + gh) + per-domain generators - const { createGitWorktreePublisher } = await import( - './infrastructure/harness-eval/publish-verdict/git-worktree-publisher.js' + // F257 sunset: verdict artifacts go to a local durable store, not Git PRs. + const { createLocalArtifactPublisher } = await import( + './infrastructure/harness-eval/publish-verdict/local-artifact-publisher.js' ); const { createA2aGeneratorAdapter } = await import( './infrastructure/harness-eval/publish-verdict/a2a-generator-adapter.js' @@ -1928,6 +2007,12 @@ async function main(): Promise { const { TaskOutcomeEpisodeStore } = await import('./infrastructure/harness-eval/task-outcome/task-outcome-store.js'); const taskOutcomeDbPath = process.env.TASK_OUTCOME_DB ?? resolve(repoRoot, 'task-outcome-episodes.sqlite'); const taskOutcomeStore = new TaskOutcomeEpisodeStore(taskOutcomeDbPath); + deleteMagicWordRefsByEventIds = (eventIds) => { + taskOutcomeStore.deleteMagicWordRefsByEventIds(eventIds); + }; + deleteMagicWordRefsByThread = (threadId) => { + taskOutcomeStore.deleteMagicWordRefsByThread(threadId); + }; // F192 Phase H 收尾 PR-2 (砚砚 R1 P1 + Q5): capability-wakeup generator wires a real // CapabilityWakeupTrialProviderImpl with all 4 required ports (sessionStore / @@ -1944,6 +2029,15 @@ async function main(): Promise { 'eval:task-outcome': createTaskOutcomeGeneratorAdapter(), 'eval:qc': createQcGeneratorAdapter(), }; + // F257 eval engine wiring: harness-ledger generator adapter (KD-17 snapshot-first). + // Generator reads stored run snapshot — no direct GuardRejectionEventLog dependency. + // Still gated on guardRejectionLog existence: snapshot provider needs it at trigger time. + if (guardRejectionLog) { + const { createHarnessLedgerGeneratorAdapter } = await import( + './infrastructure/harness-eval/publish-verdict/harness-ledger-generator-adapter.js' + ); + verdictGenerators['eval:harness-ledger'] = createHarnessLedgerGeneratorAdapter(); + } if (toolEventLog && skillLoadEventLog) { const { createCapabilityWakeupGeneratorAdapter } = await import( './infrastructure/harness-eval/publish-verdict/capability-wakeup-generator-adapter.js' @@ -2003,6 +2097,8 @@ async function main(): Promise { frustrationIssueStore, harnessFeedbackRoot: resolve(repoRoot, 'docs', 'harness-feedback'), ...(memoryServices.embeddingService ? { embeddingService: memoryServices.embeddingService } : {}), + // F257 V2: 5th friction channel — cat anomaly reports referencing pot ledgerIds. + ...(deviationEventLog ? { deviationLog: deviationEventLog, deviationOwnerUserId: 'default-user' } : {}), }); verdictGenerators['eval:friction'] = createFrictionGeneratorAdapter(frictionProvider); } @@ -2020,13 +2116,21 @@ async function main(): Promise { verdictGenerators['eval:anchor-first'] = createAnchorTelemetryGeneratorAdapter(anchorProvider); } + // F257 / F192 sunset: verdict artifacts are runtime data, persisted outside the + // product Git worktree. Prefer CAT_CAFE_DATA_DIR, then memoryServices.dataDir, + // then the canonical fallback ~/.cat-cafe. + const catCafeDataDir = process.env.CAT_CAFE_DATA_DIR ?? memoryServices.dataDir ?? join(homedir(), '.cat-cafe'); + const artifactStoreRoot = resolve(catCafeDataDir, 'harness-feedback', 'artifacts'); + const artifactPublisher = createLocalArtifactPublisher({ artifactRoot: artifactStoreRoot }); + await app.register(evalHubRoutes, { harnessFeedbackRoot: resolve(repoRoot, 'docs', 'harness-feedback'), threadStore, redis: redisClient ?? undefined, invokeTriggerProvider: invokeTriggerHolder, messageStore, - gitPublisher: createGitWorktreePublisher({ repoRoot }), + artifactPublisher, + artifactStoreRoot, verdictGenerators, // 砚砚 R4 P1 + cloud R4 P1: register CallbackAuthRegistry for MCP route auth. callbackRegistry: registry, @@ -2034,7 +2138,46 @@ async function main(): Promise { agentKeyRegistry, taskOutcomeDbPath, eventMemoryDbPath: memoryServices.eventMemoryDbPath, + // KD-17: GuardRejectionEventLog for eval:harness-ledger snapshot-first manual trigger. + guardRejectionLog, + // F257: InjectionTraceStore for per-segment judgment engine (manual trigger path). + traceStore: injectionTraceStore, + // F257 Phase D: persist latest judgments for lifeline API consumption. + judgmentCache: segmentJudgmentCache, }); + + // F257 sub-item 2: wire threshold escalation hook into GuardRejectionEventLog. + // Every event append checks guard accumulation; >= 3 events in 7 days for the + // same guard triggers an immediate eval:harness-ledger via handleTriggerNow. + // triggerEval uses invokeTriggerHolder (late-bound) — during early boot (before + // invokeTrigger construction ~line 3936), handleTriggerNow returns 503 which + // the fire-and-forget hook silently swallows (fail-open). + if (guardRejectionLog && redis) { + const { createThresholdEscalationHook } = await import( + './infrastructure/harness-eval/guard-threshold-escalation.js' + ); + const { handleTriggerNow } = await import('./infrastructure/harness-eval/manual-trigger/trigger-now.js'); + const escalationTriggerDeps: import('./infrastructure/harness-eval/manual-trigger/types.js').ManualTriggerDeps = { + harnessFeedbackRoot: resolve(repoRoot, 'docs', 'harness-feedback'), + invokeTriggerProvider: invokeTriggerHolder, + messageStore, + threadStore, + redis, + guardRejectionLog, + traceStore: injectionTraceStore, + // F257 Phase D: persist latest judgments for lifeline API. + judgmentCache: segmentJudgmentCache, + }; + guardRejectionLog.setPostAppendHook( + createThresholdEscalationHook({ + redis, + guardRejectionLog, + triggerEval: (input) => handleTriggerNow(escalationTriggerDeps, input), + }), + ); + app.log.info('[api] F257: threshold escalation hook wired into GuardRejectionEventLog'); + } + // AC-G13: Cancel burst detector (in-memory, per-process) const { buildProposalRejectSignal } = await import( './infrastructure/harness-eval/task-outcome/task-outcome-signal-builder.js' @@ -2484,6 +2627,7 @@ async function main(): Promise { messageStore, socketManager, callbackAuthNotifier, + ...(deviationEventLog ? { deviationEventLog } : {}), taskStore, backlogStore, threadStore, @@ -2528,6 +2672,7 @@ async function main(): Promise { threadStore, taskStore, ...(ballCustodyIngest ? { ballCustody: ballCustodyIngest } : {}), + ...(guardRejectionLog ? { guardRejectionLog } : {}), onHoldBallCancelFeedback: (input) => { void import('./domains/cats/services/frustration/FrustrationDetector.js') .then(({ evaluate }) => @@ -2721,6 +2866,8 @@ async function main(): Promise { origin: 'callback', timestamp: Date.now(), threadId: proposal.targetThreadId, + // F257 V1 authority embed (T-A §3.4 / §4.5.1; sol R1 P1-1 cohort audit) + ...routedProvenance('cat', analyzeA2AMentions(proposal.content, senderCatId).attemptBatch), // F257 (T-A §3.4 / §4.5.1; sol R3 P1-1) extra: { isExplicitPost: true as const, crossPost: { @@ -3151,8 +3298,8 @@ async function main(): Promise { await app.register(configRoutes); await app.register(configSecretsRoutes); await app.register(rulesRoutes); - await app.register(promptInjectionRoutes); - await app.register(promptInjectionManifestRoutes); + await app.register(promptInjectionRoutes, { overrideStore: hookOverrideStore }); + await app.register(promptInjectionManifestRoutes, { overrideStore: hookOverrideStore }); await app.register(promptInjectionPreviewRoutes); await app.register(servicesRoutes, { lifecycle: { @@ -4325,6 +4472,9 @@ async function main(): Promise { ); // N-day factory is in its own module (split from eval-domain-daily for file-size limit) const { createEvalDomainNDaySpec } = await import('./infrastructure/harness-eval/domain/eval-domain-nday.js'); + const { createTelemetryEvidencePrereqProbe } = await import( + './infrastructure/harness-eval/domain/eval-domain-evidence-gate.js' + ); const { getOwnerUserId } = await import('./config/cat-config-loader.js'); // cloud R6 P2 (PR-2) + memory wire-up: mirror the same wired set the // eval-hub.ts route computes (Object.keys(verdictGenerators)). Bootstrap-time @@ -4351,6 +4501,11 @@ async function main(): Promise { // F253 Phase C: eval:qc provider is unconditionally wired (pure ctor, zero-baseline // metrics, no runtime deps). Phase C bootstrap → keep_observe verdicts. wiredPublishDomains.add('eval:qc'); + // F257 eval engine wiring: harness-ledger domain gated on guardRejectionLog (Redis). + // Must match verdictGenerators entry above — split-brain ⇒ scheduled fire 501. + if (guardRejectionLog) { + wiredPublishDomains.add('eval:harness-ledger'); + } if (toolEventLog && skillLoadEventLog) { wiredPublishDomains.add('eval:capability-wakeup'); } @@ -4388,6 +4543,9 @@ async function main(): Promise { publishPrereqCache.set(domainId, ok); return ok; }; + const evidencePrereqProbe = createTelemetryEvidencePrereqProbe({ + otelEnabled: () => telemetryHandle.getMetricsText !== null, + }); const evalScheduleOpts = { harnessFeedbackRoot: resolve(repoRoot, 'docs', 'harness-feedback'), @@ -4397,6 +4555,14 @@ async function main(): Promise { redis: redisClient ?? undefined, wiredPublishDomains, publishPrereqProbe, + // KD-17 snapshot-first: pass guardRejectionLog so scheduled eval:harness-ledger + // trigger can produce run snapshot before eval cat invocation. + guardRejectionLog, + evidencePrereqProbe, + // F257: InjectionTraceStore for per-segment judgment engine consumption. + traceStore: injectionTraceStore, + // F257 Phase D: persist latest judgments for lifeline API consumption. + judgmentCache: segmentJudgmentCache, }; taskRunnerV2.register(createEvalDomainDailySpec(evalScheduleOpts)); taskRunnerV2.register(createEvalDomainWeeklySpec(evalScheduleOpts)); diff --git a/packages/api/src/infrastructure/connectors/ConnectorRouter.ts b/packages/api/src/infrastructure/connectors/ConnectorRouter.ts index 975afc75de..4f27757b8d 100644 --- a/packages/api/src/infrastructure/connectors/ConnectorRouter.ts +++ b/packages/api/src/infrastructure/connectors/ConnectorRouter.ts @@ -69,6 +69,7 @@ export interface ConnectorRouterOptions { source: ConnectorSource; mentions: CatId[]; timestamp: number; + provenance: { author: 'external_user' | 'system'; routed: boolean; observation: 'original' }; }): Promise<{ id: string }>; }; readonly threadStore: { @@ -304,6 +305,7 @@ export class ConnectorRouter { const { targetCatId } = parseMentions(fwdText, mentionPatterns, this.getDefaultCatId()); const fwdTimestamp = Date.now(); const fwdStored = await messageStore.append({ + provenance: { author: 'external_user', routed: false, observation: 'original' }, threadId: fwdThreadId, userId: this.opts.defaultUserId, catId: null, @@ -357,6 +359,7 @@ export class ConnectorRouter { const askCatId = cmdResult.targetCatId as CatId; const askTimestamp = Date.now(); const askStored = await messageStore.append({ + provenance: { author: 'external_user', routed: false, observation: 'original' }, threadId: askThreadId, userId: this.opts.defaultUserId, catId: null, @@ -461,6 +464,7 @@ export class ConnectorRouter { const storedTimestamp = Date.now(); const stored = await messageStore.append({ + provenance: { author: 'external_user', routed: false, observation: 'original' }, threadId: binding.threadId, userId: this.opts.defaultUserId, catId: null, @@ -617,6 +621,7 @@ export class ConnectorRouter { // Store inbound command const cmdMsg = await messageStore.append({ + provenance: { author: 'external_user', routed: false, observation: 'original' }, threadId, userId: this.opts.defaultUserId, catId: null, @@ -628,6 +633,7 @@ export class ConnectorRouter { // Store outbound system response const resMsg = await messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, // sol R3 P1-1 threadId, userId: this.opts.defaultUserId, catId: null, diff --git a/packages/api/src/infrastructure/connectors/connector-gateway-bootstrap.ts b/packages/api/src/infrastructure/connectors/connector-gateway-bootstrap.ts index f80b3a036f..b769312e9d 100644 --- a/packages/api/src/infrastructure/connectors/connector-gateway-bootstrap.ts +++ b/packages/api/src/infrastructure/connectors/connector-gateway-bootstrap.ts @@ -118,6 +118,7 @@ export interface ConnectorGatewayDeps { source: ConnectorSource; mentions: CatId[]; timestamp: number; + provenance: { author: 'external_user' | 'system'; routed: boolean; observation: 'original' }; }): Promise<{ id: string }>; getById?(id: string): Promise<{ source?: ConnectorSource } | null>; getByThreadBefore?( diff --git a/packages/api/src/infrastructure/email/ConnectorInvokeTrigger.ts b/packages/api/src/infrastructure/email/ConnectorInvokeTrigger.ts index acf1782295..98b1895fb7 100644 --- a/packages/api/src/infrastructure/email/ConnectorInvokeTrigger.ts +++ b/packages/api/src/infrastructure/email/ConnectorInvokeTrigger.ts @@ -17,7 +17,10 @@ import type { InvocationTracker } from '../../domains/cats/services/agents/invoc import type { QueueProcessor } from '../../domains/cats/services/agents/invocation/QueueProcessor.js'; import { stampVisibleTurn } from '../../domains/cats/services/agents/invocation/visible-turn.js'; import type { AgentRouter } from '../../domains/cats/services/agents/routing/AgentRouter.js'; -import type { PersistenceContext } from '../../domains/cats/services/agents/routing/route-helpers.js'; +import type { + CompletionRequirement, + PersistenceContext, +} from '../../domains/cats/services/agents/routing/route-helpers.js'; import type { IInvocationRecordStore } from '../../domains/cats/services/stores/ports/InvocationRecordStore.js'; import type { IMessageStore } from '../../domains/cats/services/stores/ports/MessageStore.js'; import { mergeTokenUsage, type TokenUsage } from '../../domains/cats/services/types.js'; @@ -62,6 +65,8 @@ export interface ConnectorTriggerPolicy { * Queue metadata may still upgrade, e.g. normal COMMENTED feedback becoming urgent CHANGES_REQUESTED. */ readonly coalesceKey?: string; + /** F257 LI-001: invocation must produce a tool action or an explicit routing exit. */ + readonly completionRequirement?: CompletionRequirement; } /** @@ -127,6 +132,7 @@ export class ConnectorInvokeTrigger { policy?.sourceCategory, policy?.suggestedSkill, policy?.coalesceKey, + policy?.completionRequirement, ); } @@ -144,6 +150,7 @@ export class ConnectorInvokeTrigger { policy?.sourceCategory, policy?.suggestedSkill, policy?.coalesceKey, + policy?.completionRequirement, ); } @@ -189,6 +196,7 @@ export class ConnectorInvokeTrigger { policy?.suggestedSkill, sender, controller, + policy?.completionRequirement, ).catch((err) => { this.opts.log.error(`[ConnectorInvokeTrigger] Unhandled: ${err instanceof Error ? err.message : String(err)}`); }); @@ -206,6 +214,7 @@ export class ConnectorInvokeTrigger { sourceCategory?: string, suggestedSkill?: string, coalesceKey?: string, + completionRequirement?: CompletionRequirement, ): Promise<'full' | 'enqueued'> { const { invocationQueue, socketManager, log } = this.opts; @@ -236,6 +245,7 @@ export class ConnectorInvokeTrigger { : {}), ...(sender ? { senderMeta: sender } : {}), ...(suggestedSkill ? { suggestedSkill } : {}), + ...(completionRequirement ? { completionRequirement } : {}), }); if (result.outcome === 'full') { @@ -292,6 +302,7 @@ export class ConnectorInvokeTrigger { suggestedSkill?: string, sender?: { id: string; name?: string }, preAcquiredController?: AbortController, + completionRequirement?: CompletionRequirement, ): Promise { const { router, socketManager, invocationRecordStore, invocationTracker, invocationQueue, log } = this.opts; const targetCats: CatId[] = [catId]; @@ -405,6 +416,7 @@ export class ConnectorInvokeTrigger { frustrationAutoIssueEligible: false, // #949 P2: Connector-sourced flows have no ball-pass expectation — suppress verdict warning verdictPassWarningEnabled: false, + ...(completionRequirement ? { completionRequirement } : {}), })) { // #768: Broadcast intent_mode on first CLI event — proves CLI is alive. if (!intentModeBroadcast) { diff --git a/packages/api/src/infrastructure/email/deliver-connector-message.ts b/packages/api/src/infrastructure/email/deliver-connector-message.ts index f998d8834c..86a4a947a6 100644 --- a/packages/api/src/infrastructure/email/deliver-connector-message.ts +++ b/packages/api/src/infrastructure/email/deliver-connector-message.ts @@ -26,6 +26,7 @@ export async function deliverConnectorMessage( input: ConnectorDeliveryInput, ): Promise { const stored = await deps.messageStore.append({ + provenance: { author: 'external_user', routed: false, observation: 'original' }, threadId: input.threadId, userId: input.userId, catId: null, diff --git a/packages/api/src/infrastructure/harness-eval/GuardRejectionEventLog.ts b/packages/api/src/infrastructure/harness-eval/GuardRejectionEventLog.ts new file mode 100644 index 0000000000..4e3704e4b2 --- /dev/null +++ b/packages/api/src/infrastructure/harness-eval/GuardRejectionEventLog.ts @@ -0,0 +1,341 @@ +/** + * F257 GuardRejectionEventLog — append-only ZSET event log for guard rejections. + * + * Uses Redis ZSET (timestamp scores) for time-windowed discovery. Fail-open: + * observation layer failures never block business calls. + * + * Closed union type with `kind` discriminator (F257 spec §2.1b). + * V2/Phase B: 6 event kinds + octet contract (ledgerId/catId/threadId/ + * invocationId/sourceTool/normalizedReason/layer/timestamp). + * + * `iterateWindow()` is the UNIQUE scan/parse/filter primitive — consumed by + * both internal `fetchWindow` and external pagewise episode counter + * (via PagewiseEventSource interface). No duplicate pagination logic. + * + * Storage: ZSET `guard-rejection:events` { eventJSON → timestamp }. + * Retention: 7 days (pruned on each append via ZREMRANGEBYSCORE). + */ + +import type { RedisClient } from '@cat-cafe/shared/utils'; +import { EVENTS_ZSET, HARD_QUERY_CAP, WINDOW_PAGE_SIZE } from './guard-rejection-constants.js'; + +// --------------------------------------------------------------------------- +// Event type definitions (closed union) +// --------------------------------------------------------------------------- + +interface GuardRejectionEventBase { + /** Per-event unique coordinate (ZSET uniqueness, sort tie-break, anchors). */ + eventId: string; + /** + * Ledger registry coordinate `{layer}/{slug}` — WHICH pot rejected + * (per-guard). Carried in rejection responses; anomaly reports quote it + * for F245 stats attribution. Never interchange with eventId (dual- + * coordinate contract). See guard-ledger-registry.ts. + */ + ledgerId: string; + /** Discriminator for closed union. */ + kind: string; + /** Thread where the rejection occurred. */ + threadId: string; + /** Cat that triggered the rejection. */ + catId: string; + /** Identifier for the guard that rejected (e.g., 'hold_ball_rate_limit'). */ + guardId: string; + /** Invocation coordinate; 'unknown' until the exact-correlation bridge lands. */ + invocationId: string; + /** Tool surface that produced the rejection (hold_ball / cross_post_message / …). */ + sourceTool: string; + /** Machine-normalized rejection reason (rate_limited / missing_wait_source_ref / …). */ + normalizedReason: string; + /** Emit surface (spec AC-B1 dual-entry requirement). */ + layer: 'api-route' | 'mcp-client' | 'generator'; + /** + * Owner scope, SERVER-injected at every emit point (sol R2 P1: the query + * surface must never leak another owner's thread/cat/invocation data). + * Read paths filter on it; pre-scope events (missing field) are invisible + * to owner-scoped readers (fail-closed for readers, 7d retention ages them out). + */ + ownerUserId: string; + /** Unix epoch ms. Also used as ZSET score. */ + timestamp: number; + /** 'window' (threadId+catId+timestamp correlation) until exact bridge lands. */ + correlationConfidence: 'window' | 'exact'; +} + +/** hold_ball 429 — maxHoldsPerWindow exceeded (HTTP route layer). */ +export interface HttpRateLimitEvent extends GuardRejectionEventBase { + kind: 'http_rate_limit'; + /** Current hold count at rejection time. */ + currentCount: number; + /** Configured maximum holds per window. */ + maxAllowed: number; + /** Window duration in ms. */ + windowMs: number; +} + +/** A2A block_pingpong — streak termination (generator layer). */ +export interface RouteDecisionBlockEvent extends GuardRejectionEventBase { + kind: 'route_decision_block'; + /** Cat that initiated the blocked A2A mention. */ + fromCatId: string; + /** Cat that was the blocked A2A target. */ + targetCatId: string; + /** Ping-pong streak count that triggered the block. */ + streakCount: number; +} + +/** Schema-shape 400 (e.g. wakeAfterMs without waitSourceRef) — HTTP route layer. */ +export interface HttpSchemaRejectEvent extends GuardRejectionEventBase { + kind: 'http_schema_reject'; +} + +/** Policy-gate 400 (gate-keeping block / routing-credential fail-closed). */ +export interface HttpPolicyRejectEvent extends GuardRejectionEventBase { + kind: 'http_policy_reject'; +} + +/** publish_verdict 403 — domain authority rejection (eval-hub route layer). */ +export interface PublishPolicyRejectEvent extends GuardRejectionEventBase { + kind: 'publish_policy_reject'; +} + +/** A2A route decision skip — generator-layer guard skipped a mention. */ +export interface RouteDecisionSkipEvent extends GuardRejectionEventBase { + kind: 'route_decision_skip'; + /** Cat that initiated the skipped A2A mention. */ + fromCatId: string; + /** Cat that was the skipped A2A target. */ + targetCatId: string; + /** Guard-specific skip reason. */ + skipReason: string; +} + +export type GuardRejectionEvent = + | HttpRateLimitEvent + | RouteDecisionBlockEvent + | HttpSchemaRejectEvent + | HttpPolicyRejectEvent + | PublishPolicyRejectEvent + | RouteDecisionSkipEvent; + +export type GuardRejectionKind = GuardRejectionEvent['kind']; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** TTL: 7 days in milliseconds — events older than this are pruned on append. */ +const RETENTION_MS = 7 * 24 * 60 * 60 * 1000; + +/** Default query limit to prevent unbounded reads. */ +const DEFAULT_QUERY_LIMIT = 200; + +/** Shared query options (sol P1-4: ledgerId is a first-class filter). */ +export interface GuardRejectionQueryOpts { + since: number; + until?: number; + guardId?: string; + ledgerId?: string; + threadId?: string; + catId?: string; + /** Owner scope (sol R2 P1): when set, only events with this ownerUserId match. */ + ownerUserId?: string; + limit?: number; +} + +// --------------------------------------------------------------------------- +// Event Log +// --------------------------------------------------------------------------- + +/** + * Post-append hook signature for threshold-driven escalation (F257 sub-item 2). + * Fires after every successful ZADD. Implementations must be fail-open + * (the hook is already wrapped in try/catch internally). + */ +export type PostAppendHook = (event: GuardRejectionEvent) => void; + +export class GuardRejectionEventLog { + private postAppendHook?: PostAppendHook; + + constructor(private readonly redis: RedisClient) {} + + /** + * Register a post-append hook (F257 sub-item 2: threshold escalation). + * Called after every successful event append — use for threshold checks + * that should react to event accumulation without waiting for weekly cron. + * The hook is wrapped in try/catch (fail-open). + */ + setPostAppendHook(hook: PostAppendHook): void { + this.postAppendHook = hook; + } + + /** + * Append a guard rejection event to the global ZSET. + * + * **Fail-open**: silently swallows all errors. Observation layer + * must never block or degrade the business call that triggered it. + * + * Also prunes events older than RETENTION_MS (idempotent, cheap — + * ZREMRANGEBYSCORE is O(log(N)+M) where M = removed count). + */ + async append(event: GuardRejectionEvent): Promise { + try { + const serialized = JSON.stringify(event); + await this.redis.zadd(EVENTS_ZSET, event.timestamp, serialized); + // Prune stale events (fail-open: errors here don't matter) + const cutoff = event.timestamp - RETENTION_MS; + await this.redis.zremrangebyscore(EVENTS_ZSET, 0, cutoff); + // F257 sub-item 2: fire post-append hook for threshold escalation. + // Fail-open — hook errors never block the business call. + if (this.postAppendHook) { + try { + this.postAppendHook(event); + } catch { + /* fail-open */ + } + } + } catch { + // Fail-open: observation layer never blocks business + } + } + + /** + * Query events in a time window. Fail-open (returns [] on error). + * LIMIT applied AFTER in-app filter (P2 fix: codex review 629795f29). + */ + async queryWindow(opts: GuardRejectionQueryOpts): Promise { + try { + const limit = opts.limit ?? DEFAULT_QUERY_LIMIT; + const { events } = await this.fetchWindow(opts); + return events.slice(0, limit); + } catch { + return []; // Fail-open + } + } + + /** + * Fail-open completeness-preserving query (sol P2-1: a silent `limit` slice + * makes episode/threshold accounting under-count without warning). + * Returns ALL window events up to HARD_QUERY_CAP with an explicit + * `truncated` marker when the cap was hit. + */ + async queryWindowComplete( + opts: Omit, + ): Promise<{ events: GuardRejectionEvent[]; truncated: boolean }> { + try { + return await this.fetchWindow(opts); + } catch { + return { events: [], truncated: false }; // Fail-open + } + } + + /** + * Strict (fail-closed) query for the eval read path. + * + * Same logic as queryWindow but does NOT swallow Redis errors. + * Eval generators MUST use this — a Redis outage must produce a 500 + * (generator throws), NOT a false "zero events" verdict. + * + * Business-facing callers keep using queryWindow (fail-open). + * + * P1 fix (codex review 04a8c368b): Redis fail-open queryWindow returned [] + * on error, which the generator misinterpreted as genuine zero events and + * wrote a false noFindingRecord verdict polluting the eval chain. + */ + async queryWindowStrict(opts: GuardRejectionQueryOpts): Promise { + const limit = opts.limit ?? DEFAULT_QUERY_LIMIT; + const { events } = await this.fetchWindow(opts); + return events.slice(0, limit); + } + + /** + * Fail-closed completeness-preserving query for the eval read path + * (sol P2-1). Redis errors propagate; `truncated` marks a HARD_QUERY_CAP + * hit so snapshot/bundle consumers can surface incompleteness explicitly + * instead of silently reporting a partial window. + */ + async queryWindowStrictComplete( + opts: Omit, + ): Promise<{ events: GuardRejectionEvent[]; truncated: boolean }> { + return this.fetchWindow(opts); + } + + /** + * Count events matching a guardId within a time window. + * Useful for threshold-driven attribution triggers (default 3/7d). + * + * **Fail-open**: returns 0 on any error. + */ + async countByGuard(guardId: string, since: number, until?: number): Promise { + const events = await this.queryWindow({ since, until, guardId }); + return events.length; + } + + /** + * Pagewise async generator: the UNIQUE scan/parse/filter primitive. + * Both `fetchWindow` (internal) and the pagewise episode counter + * (external, via PagewiseEventSource) consume this — no duplicate + * pagination logic (Fable ruling: single scan implementation). + * + * Yields matching events in timestamp order. Callers impose their own + * caps (HARD_QUERY_CAP, early-stop at k episodes, etc.) by breaking + * out of the `for await` loop — the generator stops further Redis I/O. + * + * @param stats - Optional mutable stats object; `pagesFetched` is + * incremented after each Redis page call (preserves test assertions). + */ + async *iterateWindow( + opts: Omit, + stats?: { pagesFetched: number }, + ): AsyncGenerator { + const until = opts.until ?? Date.now(); + // Exclusive upper bound: subtract 1ms from until (ZRANGEBYSCORE is inclusive). + // This aligns with PromptSegmentsSourceSelector [windowStartMs, windowEndMs). + const upperBound = until - 1; + for (let offset = 0; ; offset += WINDOW_PAGE_SIZE) { + const raw = await this.redis.zrangebyscore( + EVENTS_ZSET, + opts.since, + upperBound, + 'LIMIT', + offset, + WINDOW_PAGE_SIZE, + ); + if (stats) stats.pagesFetched++; + for (const s of raw) { + let parsed: GuardRejectionEvent; + try { + parsed = JSON.parse(s) as GuardRejectionEvent; + } catch { + continue; /* skip corrupted entries — parse errors are data-quality, not infra */ + } + if (opts.guardId && parsed.guardId !== opts.guardId) continue; + if (opts.ledgerId && parsed.ledgerId !== opts.ledgerId) continue; + if (opts.threadId && parsed.threadId !== opts.threadId) continue; + if (opts.catId && parsed.catId !== opts.catId) continue; + if (opts.ownerUserId && parsed.ownerUserId !== opts.ownerUserId) continue; + yield parsed; + } + if (raw.length < WINDOW_PAGE_SIZE) break; + } + } + + /** + * Shared window fetch: consumes `iterateWindow` up to HARD_QUERY_CAP, + * returning all matching events with an explicit `truncated` marker. + */ + private async fetchWindow( + opts: Omit, + ): Promise<{ events: GuardRejectionEvent[]; truncated: boolean }> { + const events: GuardRejectionEvent[] = []; + let truncated = false; + for await (const event of this.iterateWindow(opts)) { + if (events.length >= HARD_QUERY_CAP) { + truncated = true; + break; + } + events.push(event); + } + return { events, truncated }; + } +} diff --git a/packages/api/src/infrastructure/harness-eval/capability-wakeup/eval-capability-wakeup-live-verdict.ts b/packages/api/src/infrastructure/harness-eval/capability-wakeup/eval-capability-wakeup-live-verdict.ts index 5a1c5c3b15..769938ee77 100644 --- a/packages/api/src/infrastructure/harness-eval/capability-wakeup/eval-capability-wakeup-live-verdict.ts +++ b/packages/api/src/infrastructure/harness-eval/capability-wakeup/eval-capability-wakeup-live-verdict.ts @@ -32,9 +32,9 @@ export interface CapabilityWakeupLiveVerdictArtifact { /** * F192 Phase H 收尾 PR-2 R3 P1 (cloud): replayed raw inputs (`trials.json` + `summary.json`) * live OUTSIDE `bundleDir` at `/generated/capability-wakeup//`. - * `provenance.json` (inside bundleDir) references them by relative path + sha256, so - * publisher MUST stage this directory or the auto-PR omits the referenced raw inputs - * → reviewers/main can't audit/replay. Adapter forwards via extraStagedPaths. + * `provenance.json` (inside bundleDir) references them by relative path + sha256. + * The publisher persists the entire artifact staging root, including these raw + * inputs, so the artifact remains independently auditable and replayable. */ rawInputDir: string; packet: VerdictHandoffPacket; diff --git a/packages/api/src/infrastructure/harness-eval/deviation/DeviationEventLog.ts b/packages/api/src/infrastructure/harness-eval/deviation/DeviationEventLog.ts new file mode 100644 index 0000000000..25557223b0 --- /dev/null +++ b/packages/api/src/infrastructure/harness-eval/deviation/DeviationEventLog.ts @@ -0,0 +1,192 @@ +/** + * F257 V1 — DeviationEventLog: append-only, owner-scoped deviation ledger. + * + * Storage contract single source of truth: redesign §3.1 「DeviationEventLog + * 存储规格」(TTL=0 / 分页不静默截断 / owner 进索引与查询授权; fact 层与本账本 + * 两层不得合并) + T-C §3.6 (claim+append 同一 Lua / 幂等 idempotencyKey). + * Lua single-script pattern follows BallCustodyEventLog APPEND_LUA precedent. + */ + +import type { RedisClient } from '@cat-cafe/shared/utils'; +import { type DeviationEvent, V1_ALLOWED_UNIT_TYPES, validateDeviationEvent } from './deviation-event.js'; + +export type AppendOutcome = 'appended' | 'incident_claimed' | 'idempotent_replay'; + +export interface AppendResult { + outcome: AppendOutcome; + /** 'appended' → new event; 'incident_claimed'/'idempotent_replay' → the previously stored event */ + eventId: string; +} + +export interface DeviationQueryInput { + ownerUserId: string; + /** inclusive epoch ms */ + fromMs?: number; + /** inclusive epoch ms */ + toMs?: number; + /** page size; more pages are signalled via nextCursor — never silently truncated */ + limit?: number; + /** opaque cursor from a previous page */ + cursor?: string; +} + +export interface DeviationQueryResult { + events: DeviationEvent[]; + nextCursor: string | null; + /** indexed ids whose bodies are missing — surfaced, never silently skipped */ + missingBodies: string[]; +} + +export interface IDeviationEventLog { + /** Validates (§3.1) then atomically claims + appends (T-C). Invalid events throw — await-append, no fail-open (§4.5-2). */ + append(event: DeviationEvent, opts?: { idempotencyKey?: string }): Promise; + query(input: DeviationQueryInput): Promise; + /** Complete aggregation over [fromMs, toMs] inclusive — independent of pagination. */ + countInWindow(ownerUserId: string, fromMs: number, toMs: number): Promise; +} + +/** All owner-namespaced; TTL is never set on any of these keys (§3.1 / 铁律#5). */ +export const DeviationKeys = { + /** HASH eventId → event JSON */ + events: (owner: string) => `deviation:evt:${owner}`, + /** ZSET score=timestamp member=eventId */ + index: (owner: string) => `deviation:idx:${owner}`, + /** HASH incidentKey → eventId (T-C claim) */ + claims: (owner: string) => `deviation:claims:${owner}`, + /** HASH scoped idempotencyKey → eventId (T-C 幂等) */ + idempotency: (owner: string) => `deviation:idem:${owner}`, +} as const; + +// KEYS[1]=claims KEYS[2]=events KEYS[3]=index KEYS[4]=idempotency +// ARGV[1]=incidentKey ARGV[2]=eventId ARGV[3]=json ARGV[4]=timestamp ARGV[5]=idemKey ('' = none) +// One script ⇒ claim + append are atomic; a failed append leaves no phantom claim (T-C). +const APPEND_LUA = ` +if ARGV[5] ~= '' then + local prior = redis.call('HGET', KEYS[4], ARGV[5]) + if prior then + return {'idempotent_replay', prior} + end +end +local claimed = redis.call('HGET', KEYS[1], ARGV[1]) +if claimed then + return {'incident_claimed', claimed} +end +redis.call('HSET', KEYS[1], ARGV[1], ARGV[2]) +redis.call('HSET', KEYS[2], ARGV[2], ARGV[3]) +redis.call('ZADD', KEYS[3], ARGV[4], ARGV[2]) +if ARGV[5] ~= '' then + redis.call('HSET', KEYS[4], ARGV[5], ARGV[2]) +end +return {'appended', ARGV[2]} +`; + +const DEFAULT_PAGE_LIMIT = 1000; +const CURSOR_SEP = ':'; + +interface IndexEntry { + id: string; + score: number; +} + +function encodeCursor(entry: IndexEntry): string { + return `${entry.score}${CURSOR_SEP}${entry.id}`; +} + +function decodeCursor(cursor: string): IndexEntry | null { + const sep = cursor.indexOf(CURSOR_SEP); + if (sep <= 0) return null; + const score = Number(cursor.slice(0, sep)); + const id = cursor.slice(sep + 1); + if (!Number.isFinite(score) || !id) return null; + return { id, score }; +} + +export class RedisDeviationEventLog implements IDeviationEventLog { + constructor( + private readonly redis: RedisClient, + private readonly allowedUnitTypes: ReadonlySet = V1_ALLOWED_UNIT_TYPES, + ) {} + + async append(event: DeviationEvent, opts?: { idempotencyKey?: string }): Promise { + const errors = validateDeviationEvent(event, this.allowedUnitTypes); + if (errors.length > 0) { + throw new Error(`invalid deviation event: ${errors.join('; ')}`); + } + const owner = event.ownerUserId; + const [outcome, eventId] = (await this.redis.eval( + APPEND_LUA, + 4, + DeviationKeys.claims(owner), + DeviationKeys.events(owner), + DeviationKeys.index(owner), + DeviationKeys.idempotency(owner), + event.incidentKey, + event.eventId, + JSON.stringify(event), + String(event.timestamp), + opts?.idempotencyKey ?? '', + )) as [AppendOutcome, string]; + return { outcome, eventId }; + } + + async query(input: DeviationQueryInput): Promise { + const limit = Math.max(1, input.limit ?? DEFAULT_PAGE_LIMIT); + const toArg = input.toMs !== undefined ? String(input.toMs) : '+inf'; + const indexKey = DeviationKeys.index(input.ownerUserId); + + let boundary = input.cursor ? decodeCursor(input.cursor) : null; + if (input.cursor && !boundary) { + throw new Error(`malformed cursor: ${input.cursor}`); + } + + // Cursor walk: refetch from the boundary score (inclusive) and skip + // already-emitted members via (score, member-lex) ordering — Redis orders + // equal-score members lexicographically, so the boundary is a total order. + // Immune to offset drift from concurrent appends. + const selected: IndexEntry[] = []; + let chunk = Math.min(limit + 16, 4096); + for (;;) { + const min = boundary ? String(boundary.score) : String(input.fromMs ?? 0); + const raw = await this.redis.zrangebyscore(indexKey, min, toArg, 'WITHSCORES', 'LIMIT', 0, chunk); + const entries: IndexEntry[] = []; + for (let i = 0; i < raw.length; i += 2) { + entries.push({ id: raw[i], score: Number(raw[i + 1]) }); + } + const fresh = boundary + ? entries.filter((e) => e.score > boundary!.score || (e.score === boundary!.score && e.id > boundary!.id)) + : entries; + for (const e of fresh) { + selected.push(e); + boundary = e; + if (selected.length > limit) break; + } + if (selected.length > limit) break; + if (entries.length < chunk) break; // range exhausted + if (fresh.length === 0) chunk = Math.min(chunk * 4, 4096); // chunk was all pre-boundary ties → widen + } + + const hasMore = selected.length > limit; + const page = hasMore ? selected.slice(0, limit) : selected; + if (page.length === 0) { + return { events: [], nextCursor: null, missingBodies: [] }; + } + + const bodies = await this.redis.hmget(DeviationKeys.events(input.ownerUserId), ...page.map((e) => e.id)); + const events: DeviationEvent[] = []; + const missingBodies: string[] = []; + for (let i = 0; i < page.length; i += 1) { + const body = bodies[i]; + if (body === null || body === undefined) { + missingBodies.push(page[i].id); + continue; + } + events.push(JSON.parse(body) as DeviationEvent); + } + const last = page[page.length - 1]; + return { events, nextCursor: hasMore ? encodeCursor(last) : null, missingBodies }; + } + + async countInWindow(ownerUserId: string, fromMs: number, toMs: number): Promise { + return this.redis.zcount(DeviationKeys.index(ownerUserId), fromMs, toMs); + } +} diff --git a/packages/api/src/infrastructure/harness-eval/deviation/deviation-event.ts b/packages/api/src/infrastructure/harness-eval/deviation/deviation-event.ts new file mode 100644 index 0000000000..9f5fcea49a --- /dev/null +++ b/packages/api/src/infrastructure/harness-eval/deviation/deviation-event.ts @@ -0,0 +1,168 @@ +/** + * F257 V1 — DeviationEvent data model. + * + * Semantics single source of truth: F257 redesign §3.1 (union schema + + * attribution rules) + T-C §3.6 (manual incidentKey) + §3.1 v1.8 note + * (condition incidentKey owner namespace). Comments cite spec sections only — + * definitions live in the spec, not here. + */ + +import { createHash } from 'node:crypto'; + +export interface UnitRef { + /** §4.8②: must be a registered UnitTypeAdapter type (V1 registry = V1_ALLOWED_UNIT_TYPES) */ + unitType: string; + unitId: string; +} + +export interface DeviationAttribution { + objectiveId: string; + unitRefs: UnitRef[]; + weight: number; +} + +export interface DeviationAnchors { + threadId: string; + messageId?: string; + invocationId?: string; +} + +export type ManualObservationSource = 'operator' | 'peer' | 'self'; + +/** T-C sourceAnchor typed union. */ +export type DeviationSourceAnchor = + | { kind: 'thread_message'; messageId: string } + | { kind: 'operator_confirmation'; confirmationId: string }; + +/** §3.1 公共字段. */ +interface DeviationEventCommon { + eventId: string; + timestamp: number; + registryVersion: string; + incidentKey: string; + ownerUserId: string; + attributions: DeviationAttribution[]; + anchors: DeviationAnchors; + subjectCatId: string; +} + +export interface ConditionHitEvent extends DeviationEventCommon { + kind: 'condition_hit'; + conditionId: string; + sourceFactRef: string; + recordedBy: 'system'; +} + +export interface ManualObservationEvent extends DeviationEventCommon { + kind: 'manual_observation'; + source: ManualObservationSource; + note: string; + sourceAnchor: DeviationSourceAnchor; + /** T-C: principal-injected, never self-reported */ + recordedBy: string; +} + +export type DeviationEvent = ConditionHitEvent | ManualObservationEvent; + +/** §6 切片 V1: no condition registry exists yet — V1 manual events carry this marker. */ +export const V1_REGISTRY_VERSION = 'none'; + +/** §3.1: registered UnitTypeAdapter set (V1 仅 'segment'). */ +export const V1_ALLOWED_UNIT_TYPES: ReadonlySet = new Set(['segment']); + +const SEP = '\u0000'; // never appears in ids - composite strings cannot collide + +function anchorIdentity(anchor: DeviationSourceAnchor): string { + return anchor.kind === 'thread_message' + ? `thread_message${SEP}${anchor.messageId}` + : `operator_confirmation${SEP}${anchor.confirmationId}`; +} + +/** T-C: canonical (objectiveId, unitType, unitId) tuple set — server-sorted, weight excluded. */ +function canonicalAttributionTuples(attributions: DeviationAttribution[]): string[] { + const tuples: string[] = []; + for (const a of attributions) { + for (const ref of a.unitRefs) { + tuples.push(`${a.objectiveId}${SEP}${ref.unitType}${SEP}${ref.unitId}`); + } + } + return tuples.sort(); +} + +/** T-C incidentKey (manual branch). */ +export function manualIncidentKey( + ownerUserId: string, + sourceAnchor: DeviationSourceAnchor, + subjectCatId: string, + attributions: DeviationAttribution[], +): string { + return createHash('sha256') + .update( + JSON.stringify([ + 'manual', + ownerUserId, + anchorIdentity(sourceAnchor), + subjectCatId, + canonicalAttributionTuples(attributions), + ]), + ) + .digest('hex'); +} + +/** §3.1 v1.8: condition_hit incidentKey — owner-namespaced. */ +export function conditionIncidentKey(ownerUserId: string, conditionId: string, sourceFactRef: string): string { + return createHash('sha256') + .update(JSON.stringify(['condition', ownerUserId, conditionId, sourceFactRef])) + .digest('hex'); +} + +/** + * §3.1 attribution + per-kind rules. Returns human-readable violations + * (empty = valid). Store-level guard — the ledger never persists an event + * that violates the schema contract. + */ +export function validateDeviationEvent( + event: DeviationEvent, + allowedUnitTypes: ReadonlySet = V1_ALLOWED_UNIT_TYPES, +): string[] { + const errors: string[] = []; + if (!event.eventId) errors.push('eventId required'); + if (!Number.isFinite(event.timestamp)) errors.push('timestamp must be a finite number'); + if (!event.registryVersion) errors.push('registryVersion required'); + if (!event.incidentKey) errors.push('incidentKey required'); + if (!event.ownerUserId) errors.push('ownerUserId required'); + if (!event.anchors?.threadId) errors.push('anchors.threadId required'); + if (!event.subjectCatId) errors.push('subjectCatId required'); + + const attrs = event.attributions ?? []; + if (attrs.length === 0) errors.push('attributions must be non-empty'); + const seenObjectives = new Set(); + for (const a of attrs) { + if (!a.objectiveId) errors.push('attribution.objectiveId required'); + if (seenObjectives.has(a.objectiveId)) errors.push(`duplicate objectiveId: ${a.objectiveId}`); + seenObjectives.add(a.objectiveId); + if (!a.unitRefs || a.unitRefs.length === 0) errors.push('attribution.unitRefs must be non-empty'); + for (const ref of a.unitRefs ?? []) { + if (!allowedUnitTypes.has(ref.unitType)) errors.push(`unitType not registered: ${ref.unitType}`); + if (!ref.unitId) errors.push('unitRef.unitId required'); + } + if (typeof a.weight !== 'number' || !(a.weight > 0) || a.weight > 1) { + errors.push(`attribution.weight must be in (0,1]: ${String(a.weight)}`); + } + } + + if (event.kind === 'condition_hit') { + if (attrs.length !== 1) errors.push('condition_hit requires exactly one attribution'); + if (attrs.length === 1 && attrs[0].weight !== 1) errors.push('condition_hit attribution.weight must be 1.0'); + if (event.recordedBy !== 'system') errors.push('condition_hit recordedBy must be "system"'); + if (!event.conditionId) errors.push('conditionId required'); + if (!event.sourceFactRef) errors.push('sourceFactRef required'); + } else if (event.kind === 'manual_observation') { + if (!event.note) errors.push('note required'); + if (!event.recordedBy) errors.push('recordedBy required'); + if (!event.sourceAnchor) errors.push('sourceAnchor required'); + } else { + errors.push(`unknown kind: ${String((event as { kind?: unknown }).kind)}`); + } + return errors; +} diff --git a/packages/api/src/infrastructure/harness-eval/deviation/report-harness-signal.ts b/packages/api/src/infrastructure/harness-eval/deviation/report-harness-signal.ts new file mode 100644 index 0000000000..5a00f9bb42 --- /dev/null +++ b/packages/api/src/infrastructure/harness-eval/deviation/report-harness-signal.ts @@ -0,0 +1,198 @@ +/** + * F257 V1 — cat_cafe_report_harness_signal server side (§4.8② 语义上报层). + * + * Contract single source of truth: T-C (§3.6) — sourceAnchor typed union / + * 三条服务端校验①②③ / recordedBy+ownerUserId principal 注入 / incidentKey / + * idempotencyKey scope. Write path is await-append: failures return explicit + * errors, never fail-open (§4.5-2). Route wiring mirrors + * callback-runtime-session-routes.ts. + */ + +import { randomUUID } from 'node:crypto'; +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { + isAuthenticatedOperatorMessage, + type StoredMessage, +} from '../../../domains/cats/services/stores/ports/MessageStore.js'; +import { requireCallbackPrincipal } from '../../../routes/callback-auth-prehandler.js'; +import type { IDeviationEventLog } from './DeviationEventLog.js'; +import { + type DeviationAnchors, + type DeviationSourceAnchor, + type ManualObservationEvent, + manualIncidentKey, + V1_REGISTRY_VERSION, +} from './deviation-event.js'; + +const unitRefShape = z.object({ unitType: z.string().min(1), unitId: z.string().min(1) }).strict(); + +const attributionShape = z + .object({ + objectiveId: z.string().min(1), + unitRefs: z.array(unitRefShape).min(1), + weight: z.number().gt(0).max(1), + }) + .strict(); + +const sourceAnchorShape = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('thread_message'), messageId: z.string().min(1) }).strict(), + z.object({ kind: z.literal('operator_confirmation'), confirmationId: z.string().min(1) }).strict(), +]); + +/** T-C input contract. `.strict()`: recordedBy/ownerUserId 不是输入字段——出现即拒. */ +export const reportHarnessSignalBodySchema = z + .object({ + sourceAnchor: sourceAnchorShape, + subjectCatId: z.string().min(1), + source: z.enum(['operator', 'peer', 'self']), + note: z.string().min(1), + attributions: z.array(attributionShape).min(1), + idempotencyKey: z.string().min(1).optional(), + }) + .strict(); + +export interface ReportHarnessSignalDeps { + messageStore: { getById(id: string): StoredMessage | null | Promise }; + deviationLog: IDeviationEventLog; + /** F257 V2 AC-B2: per-pot anomaly-reference stats (optional; skipped when absent). */ + ledgerStats?: import('../guard-ledger-registry.js').GuardLedgerStats; +} + +/** Server-trusted identity (T-C: callback principal 注入, 不可自报). */ +export interface ReportPrincipal { + userId: string; + catId: string; +} + +export interface HandlerReply { + status: number; + body: Record; +} + +export async function handleReportHarnessSignal( + deps: ReportHarnessSignalDeps, + principal: ReportPrincipal, + rawBody: unknown, +): Promise { + const parsed = reportHarnessSignalBodySchema.safeParse(rawBody); + if (!parsed.success) { + const message = parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; '); + return { status: 400, body: { error: 'invalid_body', message } }; + } + const body = parsed.data; + + const resolved = await resolveAnchor(deps, principal, body.source, body.sourceAnchor); + if ('error' in resolved) return resolved.error; + + const event: ManualObservationEvent = { + kind: 'manual_observation', + eventId: `dev-${randomUUID()}`, + timestamp: Date.now(), + registryVersion: V1_REGISTRY_VERSION, + incidentKey: manualIncidentKey(principal.userId, body.sourceAnchor, body.subjectCatId, body.attributions), + ownerUserId: principal.userId, + attributions: body.attributions, + anchors: resolved.anchors, + subjectCatId: body.subjectCatId, + source: body.source, + note: body.note, + sourceAnchor: body.sourceAnchor, + recordedBy: principal.catId, + }; + + // T-C 幂等: principal+threadId scoped (仅防网络重试) + const idempotencyKey = body.idempotencyKey + ? `${principal.catId}:${resolved.anchors.threadId}:${body.idempotencyKey}` + : undefined; + + const result = await deps.deviationLog.append(event, idempotencyKey ? { idempotencyKey } : undefined); + + // F257 V2 AC-B2: an anomaly report referencing a pot coordinate increments + // that pot's stats — writeback on the WRITE side (F245 KD-4 keeps the + // friction pull path read-only). Uses result.eventId (the canonical id even + // on dedup replay) + SADD, so retries never double-count. Fail-open. + if (deps.ledgerStats) { + const { extractLedgerRefs } = await import('../guard-ledger-registry.js'); + for (const ledgerId of extractLedgerRefs(body.note)) { + // Owner-scoped (sol R2 P1): stats attribution stays within the + // reporting principal's owner space. + void deps.ledgerStats.recordAnomalyReference(principal.userId, ledgerId, result.eventId); + } + } + + return { + status: 200, + body: { outcome: result.outcome, eventId: result.eventId, incidentKey: event.incidentKey }, + }; +} + +async function resolveAnchor( + deps: ReportHarnessSignalDeps, + principal: ReportPrincipal, + source: 'operator' | 'peer' | 'self', + anchor: DeviationSourceAnchor, +): Promise<{ anchors: DeviationAnchors } | { error: HandlerReply }> { + if (anchor.kind === 'operator_confirmation') { + // T-C ①: V1 has no confirmation store yet (candidate 转正通道 lands with the + // confirm flow) — no such entity can exist, so the existence check fails honestly. + return { + error: { + status: 404, + body: { + error: 'anchor_not_found', + message: 'operator_confirmation anchors have no backing store in V1 — anchor a thread_message instead', + }, + }, + }; + } + + const msg = await deps.messageStore.getById(anchor.messageId); + // ① entity exists (tombstone = content wiped → cannot serve as evidence anchor) + if (!msg || msg._tombstone) { + return { error: { status: 404, body: { error: 'anchor_not_found' } } }; + } + // ② anchor 与 authenticated ownerUserId 同域 + if (msg.userId !== principal.userId) { + return { error: { status: 403, body: { error: 'anchor_owner_mismatch' } } }; + } + // ③ source=operator ⇒ anchor is a fresh authenticated owner assertion. + // Provenance is the identity authority; nullable catId/source alone also + // match system surfaces and derived branch copies. + if (source === 'operator' && !isAuthenticatedOperatorMessage(msg)) { + return { error: { status: 403, body: { error: 'anchor_author_not_operator' } } }; + } + return { anchors: { threadId: msg.threadId, messageId: msg.id } }; +} + +export interface ReportHarnessSignalRouteOptions { + messageStore: ReportHarnessSignalDeps['messageStore']; + /** undefined when Redis is absent — route degrades to explicit 503 (no fail-open). */ + deviationLog?: IDeviationEventLog; + /** F257 V2 AC-B2: pot stats writeback (sol P1-2 — this MUST reach the handler). */ + ledgerStats?: ReportHarnessSignalDeps['ledgerStats']; +} + +export function registerReportHarnessSignalRoute(app: FastifyInstance, opts: ReportHarnessSignalRouteOptions): void { + app.post('/api/callbacks/harness-signals/report', async (request, reply) => { + const principal = requireCallbackPrincipal(request, reply); + if (!principal) return; + if (!opts.deviationLog) { + reply.status(503); + return { error: 'deviation_log_unavailable', message: 'DeviationEventLog requires Redis' }; + } + const res = await handleReportHarnessSignal( + { + messageStore: opts.messageStore, + deviationLog: opts.deviationLog, + // sol P1-2: this adapter previously dropped ledgerStats — reports + // returned 200 with zero stats writes. + ...(opts.ledgerStats ? { ledgerStats: opts.ledgerStats } : {}), + }, + { userId: principal.userId, catId: principal.catId }, + request.body, + ); + reply.status(res.status); + return res.body; + }); +} diff --git a/packages/api/src/infrastructure/harness-eval/domain/eval-domain-daily.ts b/packages/api/src/infrastructure/harness-eval/domain/eval-domain-daily.ts index e1eb782ae3..720392835e 100644 --- a/packages/api/src/infrastructure/harness-eval/domain/eval-domain-daily.ts +++ b/packages/api/src/infrastructure/harness-eval/domain/eval-domain-daily.ts @@ -16,8 +16,22 @@ import { parse as parseYaml } from 'yaml'; import type { IThreadStore } from '../../../domains/cats/services/stores/ports/ThreadStore.js'; import type { TaskSpec_P1 } from '../../scheduler/types.js'; import { buildEvalCatInvocation } from '../eval-cat-invocation.js'; +import type { GuardRejectionEventLog } from '../GuardRejectionEventLog.js'; +import { produceHarnessLedgerRunSnapshot } from '../harness-ledger-snapshot-provider.js'; import { ensureEvalDomainThreads } from '../hub/eval-hub-thread-ensure.js'; import { inventoryLegacyTasks, type LegacyScheduledTaskLike } from '../legacy-task-cleanup.js'; +import { formatJudgmentsForEvidence, produceJudgmentsFromSnapshot } from '../manual-trigger/trigger-now-judgments.js'; +import { + buildEvidencePrereqSkippedMessage, + type EvidencePrereqProbe, + evaluateEvidencePrereq, +} from './eval-domain-evidence-gate.js'; +import { + buildHarnessLedgerSnapshotSkippedMessage, + buildHarnessLedgerZeroEventsMessage, + buildPublishPrereqSkippedMessage, + evaluatePublishPrereq, +} from './eval-domain-messages.js'; import { getEvalCatOverride } from './eval-domain-override.js'; import { type EvalDomainRegistryEntry, parseEvalDomainRegistryFile } from './eval-domain-registry.js'; @@ -30,6 +44,16 @@ export interface EvalDomainScheduleOpts { listDynamicTasks?: () => LegacyScheduledTaskLike[]; /** OQ-20: Redis client for reading evalCat overrides (community users may assign different cats). */ redis?: import('ioredis').Redis; + /** + * KD-17 snapshot-first: GuardRejectionEventLog for eval:harness-ledger + * pre-invocation snapshot production. Optional — when absent, harness-ledger + * scheduled eval skips snapshot injection. + */ + guardRejectionLog?: GuardRejectionEventLog; + /** F257 judgment engine: InjectionTraceStore for per-segment verdict production. */ + traceStore?: import('../../../domains/prompt-hooks/InjectionTraceStore.js').InjectionTraceStore; + /** F257 Phase D: SegmentJudgmentCache for persisting latest judgments for lifeline API. */ + judgmentCache?: import('../../../domains/prompt-hooks/SegmentJudgmentCache.js').SegmentJudgmentCache; /** * cloud R6 P2 (PR-2): runtime-wired publish-verdict domain set. Bootstrap (index.ts) * passes `new Set(Object.keys(verdictGenerators))` here so the scheduled daily/weekly @@ -38,6 +62,15 @@ export interface EvalDomainScheduleOpts { * → legacy default (all known-wireable domains get publish instructions in invocation). */ wiredPublishDomains?: ReadonlySet; + /** + * Pre-invocation evidence-source prerequisite probe. + * + * This runs before publishPrereqProbe because evidence production is upstream + * of verdict publishing. If the source adapter cannot produce fresh evidence, + * the scheduler posts a skip notice to the domain thread and does not invoke + * the eval cat. + */ + evidencePrereqProbe?: EvidencePrereqProbe; /** * Direction B (clowder-ai#923 fix): pre-invocation prerequisite probe. * @@ -101,46 +134,14 @@ interface EvalDomainSpecConfig extends EvalDomainScheduleOpts { triggerReasonPrefix: string; } -/** - * Direction B (clowder-ai#923): build the "publish prereq missing" status message that - * gets posted to the domain's OWN system thread when the cron skips cat invocation. - * - * The message is intentionally human-readable + has a stable header (`SKIPPED (publish - * prereq missing)`) so future eval-domain readers / log scrubbers can recognize and - * count these skips. It also points at the actionable next step (sync the runtime that - * hosts this cron, or pin the cron to a runtime that has the prereq). - */ -export function buildPublishPrereqSkippedMessage(domain: EvalDomainRegistryEntry): string { - return [ - `## Eval Domain: ${domain.domainId} — SKIPPED (publish prereq missing)`, - '', - 'The scheduled eval was skipped because the runtime hosting this cron does not', - 'export the verdict-publish prerequisites required to run this eval domain end-to-end', - '(e.g. the `isA2aSourceRefs` validator exported by `publish-verdict/validation.js`).', - '', - 'Why this matters: invoking the eval cat without the prerequisites would let it hit', - 'an infra blocker at publish time, and (per its prompt) cross-post that blocker into', - 'a feature thread — exactly the leak [clowder-ai#923] reported. The fail-closed skip', - 'keeps the failure contained in this eval domain thread.', - '', - 'Next action: ensure the runtime that hosts the eval cron has the publish-verdict', - 'fix landed, or pin the cron to a runtime that does (Direction A/C per the issue).', - ].join('\n'); -} - -export async function evaluatePublishPrereq( - probe: NonNullable, - domainId: EvalDomainRegistryEntry['domainId'], -): Promise { - // Fail-closed on throw: a probe that fails to introspect the runtime is treated as - // "prereq missing" — better to skip a recoverable eval than to invoke the cat into a - // potential cross-post leak. - try { - return await Promise.resolve(probe(domainId)); - } catch { - return false; - } -} +// Re-export message builders + evaluatePublishPrereq from extracted module. +// eval-domain-nday.ts and tests import these via eval-domain-daily. +export { + buildHarnessLedgerSnapshotSkippedMessage, + buildHarnessLedgerZeroEventsMessage, + buildPublishPrereqSkippedMessage, + evaluatePublishPrereq, +} from './eval-domain-messages.js'; function createEvalDomainSpec(config: EvalDomainSpecConfig): TaskSpec_P1 { return { @@ -193,6 +194,20 @@ function createEvalDomainSpec(config: EvalDomainSpecConfig): TaskSpec_P1 0) { + precomputedEvidence += `\n\n${formatJudgmentsForEvidence(judgments)}`; + // F257 Phase D: persist latest judgments for lifeline API consumption + await config.judgmentCache?.updateBatch(judgments); + } + } + + // F257 sub-item 1: Zero events → skip invocation (LLM cost = 0). + // Snapshot produced OK but observation window is empty — nothing to attribute. + if (snapshotResult.snapshot.totalEvents === 0) { + if (ctx.deliver) { + await ctx.deliver({ + threadId: domain.systemThreadId, + content: buildHarnessLedgerZeroEventsMessage(domain, snapshotResult.evalRunId), + userId: 'scheduler', + }); + } + return; + } + } catch (err) { + // Fail-open = skip gracefully (no retry storm), NOT invoke cat blind. + if (ctx.deliver) { + const detail = err instanceof Error ? err.message : String(err); + await ctx.deliver({ + threadId: domain.systemThreadId, + content: buildHarnessLedgerSnapshotSkippedMessage(domain, 'snapshot_error', detail), + userId: 'scheduler', + }); + } + return; + } + } + const invocation = buildEvalCatInvocation( { domain: effectiveDomain, trendRefs: [], verdictRefs: [], legacyCleanup: { status: legacyStatus }, + precomputedEvidence, }, // cloud R6 P2 (PR-2): gate scheduled invocation's publish instructions on // actual runtime support so weekly cw scheduled eval doesn't tell cat to @@ -246,7 +337,7 @@ function createEvalDomainSpec(config: EvalDomainSpecConfig): TaskSpec_P1; + +export type EvidencePrereqResult = { ok: true } | { ok: false; reason: string }; + +export type EvidencePrereqProbe = (domain: EvidenceGateDomain) => EvidencePrereqResult | Promise; + +/** + * Source adapters whose evidence pipeline hard-requires live OTel telemetry. + * Registry `sourceAdapter` is a free slug (see eval-domain-registry.ts), so + * the adapter → prerequisite mapping lives here, next to the probe. + */ +const TELEMETRY_BACKED_ADAPTERS: ReadonlySet = new Set(['f167-runtime-eval']); + +export function isTelemetryBackedAdapter(sourceAdapter: string): boolean { + return TELEMETRY_BACKED_ADAPTERS.has(sourceAdapter); +} + +/** + * Probe factory. Bootstrap wires `otelEnabled: () => !!telemetryHandle.getMetricsText` + * — the same init-state signal `GET /api/telemetry/health` reports as + * `otelEnabled` (routes/telemetry.ts Phase K note: actual init state, not an + * env-var proxy). Non-telemetry-backed adapters always pass through. + */ +export function createTelemetryEvidencePrereqProbe(opts: { + otelEnabled: () => boolean; + /** Override the reason text; defaults to the health route's disabledReason derivation. */ + disabledReason?: () => string; +}): EvidencePrereqProbe { + return (domain) => { + if (!isTelemetryBackedAdapter(domain.sourceAdapter)) return { ok: true }; + if (opts.otelEnabled()) return { ok: true }; + const reason = + opts.disabledReason?.() ?? + (process.env.OTEL_SDK_DISABLED === 'true' + ? 'OTel disabled by OTEL_SDK_DISABLED=true' + : 'OTel disabled at boot: HMAC salt validation failed (TELEMETRY_HMAC_SALT not configured)'); + return { ok: false, reason }; + }; +} + +/** Fail-closed evaluation: a probe that throws is treated as "evidence unavailable". */ +export async function evaluateEvidencePrereq( + probe: EvidencePrereqProbe, + domain: EvidenceGateDomain, +): Promise { + try { + return await Promise.resolve(probe(domain)); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { ok: false, reason: `evidence prereq probe threw: ${message}` }; + } +} + +/** + * Stable-header skip notice posted to the domain's OWN system thread when the + * cron fails closed. Header format mirrors `buildPublishPrereqSkippedMessage` + * so eval-domain readers / log scrubbers can grep both skip classes uniformly. + */ +export function buildEvidencePrereqSkippedMessage(domain: EvidenceGateDomain, reason: string): string { + return [ + `## Eval Domain: ${domain.domainId} — SKIPPED (evidence source unavailable)`, + '', + "The scheduled eval was skipped because this domain's evidence source", + `(\`${domain.sourceAdapter}\`) cannot produce evidence on this runtime:`, + '', + `> ${reason}`, + '', + 'Why this matters: invoking the eval cat without a live evidence source', + 'burns a full LLM session to re-conclude the same telemetry gap every fire', + '(see the eval:a2a 2026-06-30 → 2026-07-07 verdict series). The fail-closed', + 'skip keeps the gap visible in this thread at zero LLM cost.', + '', + 'Next action: configure a non-empty `TELEMETRY_HMAC_SALT` for the API', + 'runtime and restart it (OTel initializes at boot), or set `enabled: false`', + "in this domain's registry YAML to pause the schedule intentionally.", + ].join('\n'); +} diff --git a/packages/api/src/infrastructure/harness-eval/domain/eval-domain-messages.ts b/packages/api/src/infrastructure/harness-eval/domain/eval-domain-messages.ts new file mode 100644 index 0000000000..f926aecc63 --- /dev/null +++ b/packages/api/src/infrastructure/harness-eval/domain/eval-domain-messages.ts @@ -0,0 +1,116 @@ +/** + * Eval domain message builders — human-readable skip/status messages for + * eval:harness-ledger and other domains. + * + * Extracted from eval-domain-daily.ts (file-size 350-line hard limit). + * Each message has a stable header (`SKIPPED (...)`) for log scrubbers and + * eval-domain readers to recognize and count programmatically. + */ + +import type { EvalDomainRegistryEntry } from './eval-domain-registry.js'; + +// --------------------------------------------------------------------------- +// Direction B (clowder-ai#923): publish prereq skip +// --------------------------------------------------------------------------- + +/** + * Build the "publish prereq missing" status message posted to the domain's + * own system thread when the cron skips cat invocation. + */ +export function buildPublishPrereqSkippedMessage(domain: EvalDomainRegistryEntry): string { + return [ + `## Eval Domain: ${domain.domainId} — SKIPPED (publish prereq missing)`, + '', + 'The scheduled eval was skipped because the runtime hosting this cron does not', + 'export the verdict-publish prerequisites required to run this eval domain end-to-end', + '(e.g. the `isA2aSourceRefs` validator exported by `publish-verdict/validation.js`).', + '', + 'Why this matters: invoking the eval cat without the prerequisites would let it hit', + 'an infra blocker at publish time, and (per its prompt) cross-post that blocker into', + 'a feature thread — exactly the leak [clowder-ai#923] reported. The fail-closed skip', + 'keeps the failure contained in this eval domain thread.', + '', + 'Next action: ensure the runtime that hosts the eval cron has the publish-verdict', + 'fix landed, or pin the cron to a runtime that does (Direction A/C per the issue).', + ].join('\n'); +} + +// --------------------------------------------------------------------------- +// KD-17 snapshot-first: harness-ledger skip messages +// --------------------------------------------------------------------------- + +/** + * Build the "snapshot unavailable" skip message for eval:harness-ledger + * when the scheduled cron cannot produce a guard rejection snapshot + * (provider absent or snapshot production failed). + * + * Same pattern as `buildPublishPrereqSkippedMessage`: human-readable with + * a stable header for log scrubbers, domain-local delivery, no cat invocation. + */ +export function buildHarnessLedgerSnapshotSkippedMessage( + domain: EvalDomainRegistryEntry, + reason: 'provider_not_wired' | 'snapshot_error' | 'owner_scope_missing', + detail?: string, +): string { + const reasonText = + reason === 'provider_not_wired' + ? 'GuardRejectionEventLog provider is not wired at runtime (guardRejectionLog absent in config).' + : reason === 'owner_scope_missing' + ? 'defaultUserId not configured — snapshot requires owner scope (sol R9 P1-2: fail-closed, never use synthetic placeholder).' + : `Snapshot production failed: ${detail ?? 'unknown error'}.`; + return [ + `## Eval Domain: ${domain.domainId} — SKIPPED (harness ledger snapshot unavailable)`, + '', + `KD-17 snapshot-first invariant: eval cat must not be invoked without`, + `pre-computed guard rejection evidence. ${reasonText}`, + '', + `This is a graceful skip — the cron task itself did not error.`, + `The eval cat was NOT invoked (no LLM cost, no blind verdict).`, + '', + `Next action: ensure GuardRejectionEventLog is wired and Redis is reachable`, + `at the next scheduled fire.`, + ].join('\n'); +} + +/** + * Build the "zero events" skip message for eval:harness-ledger. + * + * Snapshot produced successfully but contains zero guard rejection events + * in the observation window. Nothing to attribute → skip cat invocation + * (LLM cost = 0). Normal during quiet periods. + * + * F257 eval-trigger sub-item 1: don't invoke eval cat on empty windows. + */ +export function buildHarnessLedgerZeroEventsMessage(domain: EvalDomainRegistryEntry, evalRunId: string): string { + return [ + `## Eval Domain: ${domain.domainId} — SKIPPED (zero events in window)`, + '', + `KD-17 snapshot-first: snapshot produced successfully (evalRunId: \`${evalRunId}\`)`, + 'but contains zero guard rejection events in the observation window.', + '', + 'No attribution analysis needed — eval cat NOT invoked (LLM cost = 0).', + 'This is normal during quiet periods (baseline accumulation).', + '', + 'The weekly cron will re-check at the next scheduled fire.', + ].join('\n'); +} + +// --------------------------------------------------------------------------- +// Publish prereq evaluation helper +// --------------------------------------------------------------------------- + +/** + * Evaluate whether the runtime satisfies the publish-verdict prerequisites + * for a given eval domain. Fail-closed: a probe that throws is treated as + * "prereq missing" (better to skip than to leak). + */ +export async function evaluatePublishPrereq( + probe: (domainId: EvalDomainRegistryEntry['domainId']) => boolean | Promise, + domainId: EvalDomainRegistryEntry['domainId'], +): Promise { + try { + return await Promise.resolve(probe(domainId)); + } catch { + return false; + } +} diff --git a/packages/api/src/infrastructure/harness-eval/domain/eval-domain-nday.ts b/packages/api/src/infrastructure/harness-eval/domain/eval-domain-nday.ts index 44845706ed..5f24e0300b 100644 --- a/packages/api/src/infrastructure/harness-eval/domain/eval-domain-nday.ts +++ b/packages/api/src/infrastructure/harness-eval/domain/eval-domain-nday.ts @@ -20,6 +20,7 @@ import { type EvalDomainScheduleOpts, evaluatePublishPrereq, } from './eval-domain-daily.js'; +import { buildEvidencePrereqSkippedMessage, evaluateEvidencePrereq } from './eval-domain-evidence-gate.js'; import { getEvalCatOverride } from './eval-domain-override.js'; import { type EvalDomainRegistryEntry, parseEvalDomainRegistryFile } from './eval-domain-registry.js'; @@ -151,6 +152,20 @@ export function createEvalDomainNDaySpec(opts: EvalDomainScheduleOpts): TaskSpec ); } + if (opts.evidencePrereqProbe) { + const evidencePrereq = await evaluateEvidencePrereq(opts.evidencePrereqProbe, domain); + if (!evidencePrereq.ok) { + if (ctx.deliver) { + await ctx.deliver({ + threadId: domain.systemThreadId, + content: buildEvidencePrereqSkippedMessage(domain, evidencePrereq.reason), + userId: 'scheduler', + }); + } + return; + } + } + // Direction B publish-prereq gate (same as daily/weekly spec) if (opts.publishPrereqProbe) { const prereqOk = await evaluatePublishPrereq(opts.publishPrereqProbe, domain.domainId); diff --git a/packages/api/src/infrastructure/harness-eval/eval-cat-invocation.ts b/packages/api/src/infrastructure/harness-eval/eval-cat-invocation.ts index 7a2d91f7ab..b8adaded6b 100644 --- a/packages/api/src/infrastructure/harness-eval/eval-cat-invocation.ts +++ b/packages/api/src/infrastructure/harness-eval/eval-cat-invocation.ts @@ -10,6 +10,12 @@ export interface EvalCatInvocationInput { trendRefs: string[]; verdictRefs: string[]; legacyCleanup: LegacyCleanupStatus; + /** + * KD-17 snapshot-first: pre-computed evidence summary for domains that + * produce a run snapshot before eval cat invocation (e.g. eval:harness-ledger). + * Injected into the invocation context so the eval cat sees real data. + */ + precomputedEvidence?: string; } export interface EvalCatInvocationPacket { @@ -26,6 +32,8 @@ export interface EvalCatInvocationPacket { legacyCleanup: LegacyCleanupStatus; sla: EvalDomainRegistryEntry['sla']; }; + /** KD-17: pre-computed evidence summary (injected as extra content, not in context JSON). */ + precomputedEvidence?: string; } const DOMAIN_INSTRUCTIONS: Partial> = { @@ -43,6 +51,8 @@ const DOMAIN_INSTRUCTIONS: Partial> = { 'Enter the eval:friction domain thread. Review the periodic cross-channel friction rollup report (clusters aggregated from paw-feel markers, tool-call cancels, user feedback, and eval-domain metrics). For each Top-N cluster, weigh its sensor forms (provided in the report), channel diversity (cross-channel recurrence = stronger signal), count, severity, and member evidence refs. The report does NOT pre-assign root cause — YOU assign the 7-class root cause as your own verdict-layer attribution judgment (harness_misfit / tool_gap / environment_drift / vision_gap / translation_gap / execution_gap / taste_gap); do not fabricate attribution — if the evidence is thin, lower your confidence or say so. Phase D contract: `actionableCandidates` are the only clusters eligible for a repair-thread exit, and each may carry a prefilled `followupDraft` you can reuse when you decide a propose_thread is warranted. `referenceOnly` clusters are link-only context (currently eval-domain friction): list / cite them, but do NOT open a second repair thread for them. Produce a verdict handoff packet (fix/build/keep_observe/delete_sunset). Cluster counts + sensor forms are evidence, not the packet verdict. Do not over-fold the long tail — a low-count cluster on a high-severity channel can still warrant a fix verdict.', 'eval:qc': 'Enter the eval:qc domain thread. Analyze the weekly QC pipeline metrics rollup: finding yield (average actionable findings per review), false positive rate (findings rejected by author / total), reviewer delta (formal reviewer new findings vs fresh-context pre-review coverage), and post-merge bug rate (hotfixes within 14-day window per merged PR). Phase C bootstrap provides zero-baseline data — produce a keep_observe verdict noting the zero-data state. As live telemetry sources are wired (future phases), compare week-over-week trends and produce fix/build/keep_observe/delete_sunset verdicts based on whether the QC loop is improving review quality.', + 'eval:harness-ledger': + 'Enter the eval:harness-ledger domain thread. Analyze the prompt-segments guard rejection event log: review guard rejection events (http_rate_limit, route_decision_block) within the query window, identify patterns in rejection frequency and guard activation, compare window-over-window trends, and produce a verdict handoff packet when evidence supports fix/build/keep_observe/delete_sunset. Guard rejection events carry threadId + catId + guardId correlation at window confidence level. Focus on whether guards are firing at expected rates (healthy) or show anomalies (too frequent = possible misconfiguration, too rare = possible bypass). Event counts and guard distributions are evidence, not the packet verdict.', 'eval:anchor-first': 'Enter the eval:anchor-first domain thread. Analyze the anchor-first preview↔drill open-rate telemetry rollup: per-tool preview response counts, previewed items, drilled unique items, open-rate (drilledUniqueItems / previewedItems), charsSaved (originalChars - returnedChars), drillChars, and double-sided netBenefit (charsSaved - drillChars). Each rollup covers the LATEST 24h in-memory snapshot (event buffer has 24h retention; the weekly firing frequency is how often the eval cat runs, NOT the data window). Compare per-tool stats across the 4 preview tools (pending-mentions, thread-context, list-tasks, get-message) and 2 drill tools (get-message, list-tasks). Also review Adoption Detail / activationCounts adoption_* fields: explicitAnchorCalls, explicitFullCalls, defaultAnchorCalls, defaultFullCalls, legacyEquivalentAnchorCalls, and uniqueCatsExplicitAnchor answer whether cats are actively choosing anchor or only hitting defaults / old equivalent controls. orphanDrills indicates drills whose itemId matched no preview in the window (stale drill pointers, drills outside window, items surfaced before the event log started, or drills that arrived before any preview of that item — temporal causality enforced). Track-1 aggregate snapshot is cross-referenced for volume sanity checks. SUNSET SIGNAL CRITERIA (AC-E3, 双信号 — both required for delete_sunset): The attribution bundle includes pre-computed sunsetSignals per tool and a sunsetAssessment summary. Signal 1 (anchor tax): sunsetSignals.anchorTax=true when openRateByItem > 80% AND netBenefit < 0 — cats drill almost everything, anchor saves nothing; frictionSignal.severity is escalated to high, proposedAction is fix (not sunset — generator cannot confirm Signal 2 blindness; only eval cat escalates to delete_sunset after cross-referencing task-outcome). Signal 2 (blindness — MORE dangerous, token account INVISIBLE): reference-read the latest eval:task-outcome verdict/trend — if task-outcome quality (corrected_success / needs_investigation rates) worsened after anchor deployment and correlates with anchor tool usage, this is the insidious signal that preview is causing judgment errors. F236 does NOT write to eval:task-outcome; cross-reference only. VERDICT MAPPING: Both signals (tax + blindness evidence) → delete_sunset with governance.cvoAcceptRequired=true; ownerAsk.requestedAction MUST specify WHICH tool(s) to sunset. Signal 1 only (tax, no blindness evidence) → fix (investigate whether preview quality can improve to reduce drill rate). Signal 2 only (blindness, no clear tax) → fix (urgent: preview may be causing judgment errors, investigate). Neither signal + healthy data → keep_observe (log as Phase C expansion data basis). Insufficient data (low confidence / few preview events) → keep_observe with note on sample size. For delete_sunset verdicts: specify per-tool sunset in ownerAsk (e.g. "sunset anchor on thread-context, keep anchor on pending-mentions").', }; @@ -52,8 +62,9 @@ const DOMAIN_INSTRUCTIONS: Partial> = { * * Replaces abandoned PR #2091 教学 ('git add + git commit + git push origin * main' violates §5 rule #2 — review must be cross-individual). Eval cats - * now publish through `cat_cafe_publish_verdict` MCP tool which validates - * packet schema, calls generator, creates isolated branch, opens auto-PR. + * now publish through `cat_cafe_publish_verdict` MCP tool which validates the + * packet, calls the generator, and atomically stores a durable runtime artifact + * outside the product Git checkout. * * Appended to all 5 domain instructions so cats see consistent publish path * regardless of which domain they're working on. @@ -70,7 +81,7 @@ When your analysis converges to a verdict, call the \`cat_cafe_publish_verdict\` 3. **createdAt** — ISO 8601 timestamp 4. **phenomenon** — what you observed (1-2 sentences) 5. **harnessUnderEval** — { featureId, componentId, name } of harness being evaluated -6. **evidencePacket** — { snapshotRefs, attributionRefs, metricRefs, sampleTraceRefs } — concrete refs to committed bundle artifacts, NOT raw narrative. \`sampleTraceRefs\` must be NON-EMPTY even on no-finding packets — pass at least one metadata-only ref so the bundle has a stable anchor (the schema validator rejects empty arrays at submit time). +6. **evidencePacket** — { snapshotRefs, attributionRefs, metricRefs, sampleTraceRefs } — concrete refs to durable bundle artifacts, NOT raw narrative. \`sampleTraceRefs\` must be NON-EMPTY even on no-finding packets — pass at least one metadata-only ref so the bundle has a stable anchor (the schema validator rejects empty arrays at submit time). 7. **dailyTrend** — { window, current, baseline, threshold, direction } — quantitative trend data. \`current\` / \`baseline\` / \`threshold\` are each a **record/object whose values are numbers** (Zod \`record(number)\`) — e.g. \`current: { verdictWithoutPass: 9 }\`. Bare number primitives (\`current: 9\`), strings (\`"3/10"\`), null, and nested-object values are rejected by the schema at submit time. \`window\` is a string label (e.g. \`"24h"\`); \`direction\` is the enum \`improved\` / \`regressed\` / \`flat\` / \`unknown\`. 8. **rootCauseHypothesis** — { summary, confidence (low/medium/high), alternatives[] } 9. **verdict** — categorical: \`fix\` / \`build\` / \`keep_observe\` / \`delete_sunset\` (NOT a score) @@ -79,30 +90,27 @@ When your analysis converges to a verdict, call the \`cat_cafe_publish_verdict\` 12. **counterarguments** — non-empty array of alternative interpretations 13. **governance** (OPTIONAL except for \`delete_sunset\` verdict, where \`governance.cvoAcceptRequired: true\` is REQUIRED) -## After publishing — PR lifecycle (MANDATORY) +## After publishing — artifact lifecycle (MANDATORY) -The MCP tool returns a PR URL. Your job is NOT done at publish — follow through: +The MCP tool returns an artifact ID and \`artifact://\` URL. Runtime verdict evidence belongs in this durable artifact store, not in the product Git repository. -### Evidence-only verdict PR (\`keep_observe\` / first-round verdicts) -1. The PR contains only docs/evidence files (no code). You are the domain owner — **self-merge via \`gh pr merge --squash --delete-branch\`** after confirming the PR is clean (no unintended files). -2. Post a summary in your domain thread: verdict direction + PR URL + next eval schedule. +- Post a summary in your domain thread: verdict direction + artifact URL + next eval schedule. +- For \`fix\` / \`build\` / \`delete_sunset\`, cross-post the owner named by \`ownerAsk.targetOwnerCatId\` with the verdict summary, artifact URL, and exact \`requestedAction\`. +- \`provenance.json → sourceThreadId\` preserves which domain thread produced the artifact. +- **Do not** run \`git add\`, \`git commit\`, \`git push\`, create an evidence PR, or merge an evidence PR for runtime verdict data. A later code/documentation fix is a separate normal PR with cross-review. +`; -### Actionable verdict PR (\`fix\` / \`build\` / \`delete_sunset\`) -1. Merge the evidence PR yourself (same as above — evidence is evidence regardless of verdict direction). -2. The \`ownerAsk.targetOwnerCatId\` in your verdict identifies who should act on the finding. **Cross-post to that owner's thread** via \`cat_cafe_cross_post_message\` with: verdict summary, PR URL, and the specific \`requestedAction\`. -3. If the owner creates a fix/build PR with code changes, that PR follows normal cross-review merge-gate (NOT self-merge). +const PUBLISH_VERDICT_ARTIFACT_RESULT_INSTRUCTIONS = ` +The MCP tool atomically publishes the verdict and replay bundle outside the product Git checkout. It returns \`{ artifactId, artifactUrl, verdictPath, bundleDir }\`. Use the artifact URL for traceability and handoff. -### Thread traceability -Include your domain thread ID in the verdict PR body (the MCP tool does this automatically via provenance.json). If someone asks "which thread produced this PR", the answer is in \`provenance.json → sourceThreadId\`. +**DO NOT** run \`git add\`, \`git commit\`, \`git push\`, create a verdict PR, or write verdict files into the product checkout. Use the MCP tool. `; /** a2a-specific sourceRefs section (snapshot/attribution YAML basenames). */ const PUBLISH_VERDICT_INSTRUCTIONS_A2A = `${PUBLISH_VERDICT_PACKET_INSTRUCTIONS} You must also supply \`sourceRefs\` (NOT part of packet, separate input field): \`{ snapshotName, attributionName }\` — BASENAMES of your sanitized evidence YAMLs inside \`/snapshots/\` and \`/attributions/\` respectively. Path separators / \`..\` will be rejected (allowlist). The tool will NOT fabricate evidence — if you don't provide refs, publish fails. -The MCP tool creates branch \`verdict/auto/{domainSlug}/{verdictId}\` + commits + opens PR. Returns commit SHA + PR URL. - -**DO NOT** run \`git add\`, \`git commit\`, \`git push\`, or write verdict files directly. Use the MCP tool. +${PUBLISH_VERDICT_ARTIFACT_RESULT_INSTRUCTIONS} `; /** @@ -129,9 +137,7 @@ Fields: Tool resolves the selector by replaying session events via \`buildCapabilityTrace → evaluateCapabilityWakeupTrace → classifyCapabilityWakeupTrials\` — no need for you to pre-sanitize evidence YAMLs. Tool will NOT fabricate evidence — if selector yields zero classified trials, publish fails. -The MCP tool creates branch \`verdict/auto/{domainSlug}/{verdictId}\` + commits + opens PR. Returns commit SHA + PR URL. - -**DO NOT** run \`git add\`, \`git commit\`, \`git push\`, or write verdict files directly. Use the MCP tool. +${PUBLISH_VERDICT_ARTIFACT_RESULT_INSTRUCTIONS} `; const PUBLISH_VERDICT_INSTRUCTIONS_TASK_OUTCOME = `${PUBLISH_VERDICT_PACKET_INSTRUCTIONS} @@ -154,11 +160,9 @@ Fields: - \`evidenceCatId\` — OPTIONAL cat filter for event-memory evidence linking - \`episodeVerdicts\` — OPTIONAL explicit 7-class writeback list for terminal episodes in the selected window. Use only after reviewing the episode evidence. Valid verdicts: \`success\`, \`corrected_success\`, \`needs_investigation\`, \`harness_fix_needed\`, \`routing_failure\`, \`taste_mismatch\`, \`abandoned\` -Tool resolves the selector by loading task-outcome episodes/signals for the time window, bundling replay data under \`docs/harness-feedback/bundles//raw/\`, writing the live verdict artifacts in the isolated worktree, and applying any explicit \`episodeVerdicts\` to the task-outcome DB. Tool will NOT fabricate evidence — if the DB path is missing, the selector is invalid, or an \`episodeVerdicts[].episodeId\` is outside the selected terminal window, publish fails. - -The MCP tool creates branch \`verdict/auto/{domainSlug}/{verdictId}\` + commits + opens PR. Returns commit SHA + PR URL. +Tool resolves the selector by loading task-outcome episodes/signals for the time window, bundling replay data under the artifact staging root, and applying any explicit \`episodeVerdicts\` to the task-outcome DB only after durable publication succeeds. Tool will NOT fabricate evidence — if the DB path is missing, the selector is invalid, or an \`episodeVerdicts[].episodeId\` is outside the selected terminal window, publish fails. -**DO NOT** run \`git add\`, \`git commit\`, \`git push\`, or write verdict files directly. Use the MCP tool. +${PUBLISH_VERDICT_ARTIFACT_RESULT_INSTRUCTIONS} `; /** @@ -186,9 +190,9 @@ Fields: Tool resolves the selector by calling \`RecallMetricsComputer.computeMetrics({days, catId, toolName})\` + \`computeLibraryHealth(...)\` — no need for you to pre-sanitize evidence YAMLs. Tool will NOT fabricate evidence — if the window yields zero recall events (\`totalEvents=0\`), publish fails with \`404 no_metrics_in_window\` so you widen the window or relax the filters before retrying. -The MCP tool creates branch \`verdict/auto/{domainSlug}/{verdictId}\` + commits + opens PR. Returns commit SHA + PR URL. Bundle contains snapshot.json + attribution.json + provenance.json (sha256 of \`generated/memory/{verdictId}/{recall-metrics,library-health}.json\` for replay). +The artifact bundle contains snapshot.json + attribution.json + provenance.json, including sha256 provenance for the replay inputs. -**DO NOT** run \`git add\`, \`git commit\`, \`git push\`, or write verdict files directly. Use the MCP tool. +${PUBLISH_VERDICT_ARTIFACT_RESULT_INSTRUCTIONS} `; /** @@ -222,9 +226,7 @@ Fields: Tool resolves the selector by building a SopTrace from the embedded trace data, loading the SOP definition from the shared catalog, running \`evaluateSopDefinition(definition, trace)\`, and writing the results as bundle artifacts (snapshot.json, attribution.json, provenance.json) + raw inputs (trace.json, eval-results.json). Tool will NOT fabricate evidence — if the trace fails schema validation or the definition ID is unknown, publish fails. -The MCP tool creates branch \`verdict/auto/{domainSlug}/{verdictId}\` + commits + opens PR. Returns commit SHA + PR URL. - -**DO NOT** run \`git add\`, \`git commit\`, \`git push\`, or write verdict files directly. Use the MCP tool. +${PUBLISH_VERDICT_ARTIFACT_RESULT_INSTRUCTIONS} `; const PUBLISH_VERDICT_INSTRUCTIONS_FRICTION = `${PUBLISH_VERDICT_PACKET_INSTRUCTIONS} @@ -245,11 +247,9 @@ Fields: - \`topN\` — OPTIONAL deep-dive quota override (positive integer; default 10 — Top-N clusters keep full member evidence, the long tail is folded into a summary) - \`tokenCap\` — OPTIONAL token hard-cap override (positive integer; default 4000) -Tool resolves the selector by composing the 4 read-only friction channels (paw-feel markers / tool-call cancels / user feedback / eval-domain metrics) over the window, aggregating + clustering into a FrictionRollupReport, and bundling replay data under \`docs/harness-feedback/bundles//raw/\`. Read-only (KD-4): no writeback to any source store. Tool will NOT fabricate evidence — an empty window yields a no-finding record, not invented clusters. - -The MCP tool creates branch \`verdict/auto/{domainSlug}/{verdictId}\` + commits + opens PR. Returns commit SHA + PR URL. +Tool resolves the selector by composing the 4 read-only friction channels (paw-feel markers / tool-call cancels / user feedback / eval-domain metrics) over the window, aggregating + clustering into a FrictionRollupReport, and bundling replay data under the artifact staging root. Read-only (KD-4): no writeback to any source store. Tool will NOT fabricate evidence — an empty window yields a no-finding record, not invented clusters. -**DO NOT** run \`git add\`, \`git commit\`, \`git push\`, or write verdict files directly. Use the MCP tool. +${PUBLISH_VERDICT_ARTIFACT_RESULT_INSTRUCTIONS} `; const PUBLISH_VERDICT_INSTRUCTIONS_ANCHOR_FIRST = `${PUBLISH_VERDICT_PACKET_INSTRUCTIONS} @@ -268,9 +268,7 @@ Fields: Tool resolves the selector by computing the anchor telemetry rollup over the specified window (per-tool preview↔drill join, open-rate, double-sided netBenefit, orphanDrills) and bundling the rollup snapshot + Track-1 aggregate cross-reference. Tool will NOT fabricate evidence — if the window yields zero preview events, the rollup is empty (no perTool entries). -The MCP tool creates branch \`verdict/auto/{domainSlug}/{verdictId}\` + commits + opens PR. Returns commit SHA + PR URL. - -**DO NOT** run \`git add\`, \`git commit\`, \`git push\`, or write verdict files directly. Use the MCP tool. +${PUBLISH_VERDICT_ARTIFACT_RESULT_INSTRUCTIONS} `; const PUBLISH_VERDICT_INSTRUCTIONS_QC = `${PUBLISH_VERDICT_PACKET_INSTRUCTIONS} @@ -289,9 +287,22 @@ Fields: Tool resolves the selector by computing the QC metrics rollup over the specified window and bundling the snapshot. Phase C bootstrap: metrics are zero-baseline (no live data source wired yet). Tool will NOT fabricate evidence. -The MCP tool creates branch \`verdict/auto/{domainSlug}/{verdictId}\` + commits + opens PR. Returns commit SHA + PR URL. +${PUBLISH_VERDICT_ARTIFACT_RESULT_INSTRUCTIONS} +`; + +const PUBLISH_VERDICT_INSTRUCTIONS_HARNESS_LEDGER = `${PUBLISH_VERDICT_PACKET_INSTRUCTIONS} +You must also supply \`sourceRefs\` (NOT part of packet, separate input field) as a replayable prompt-segments selector. + +**Copy the exact sourceRefs JSON from the "Pre-computed Guard Rejection Snapshot" section in your invocation message.** The snapshot section includes a fenced JSON block with the exact \`kind\`, \`windowStartMs\`, \`windowEndMs\`, and \`evalRunId\` values. Copy them verbatim — do NOT convert, round, or re-derive any values. + +Fields: +- \`kind\` — REQUIRED literal \`"prompt-segments"\` +- \`windowStartMs\` / \`windowEndMs\` — REQUIRED exact epoch-ms values from the snapshot section. The generator verifies these match the stored snapshot's window exactly — any difference (even 1ms) is rejected. +- \`evalRunId\` — REQUIRED string from the snapshot section. The generator reads the stored snapshot by this ID (single-read, fail-closed on missing). Must match format \`hlr--\`. + +**Snapshot-first (KD-17)**: Your invocation message includes a pre-computed guard rejection snapshot with event counts, guard distributions, and the complete sourceRefs. Use this data for your verdict analysis — it IS the evidence. The generator reuses the same stored snapshot at publish time (no re-query). Decision and artifact share one data source. -**DO NOT** run \`git add\`, \`git commit\`, \`git push\`, or write verdict files directly. Use the MCP tool. +${PUBLISH_VERDICT_ARTIFACT_RESULT_INSTRUCTIONS} `; const PUBLISH_VERDICT_INSTRUCTIONS_BY_DOMAIN: Partial> = { @@ -303,6 +314,7 @@ const PUBLISH_VERDICT_INSTRUCTIONS_BY_DOMAIN: Partial> = 'eval:friction': PUBLISH_VERDICT_INSTRUCTIONS_FRICTION, 'eval:anchor-first': PUBLISH_VERDICT_INSTRUCTIONS_ANCHOR_FIRST, 'eval:qc': PUBLISH_VERDICT_INSTRUCTIONS_QC, + 'eval:harness-ledger': PUBLISH_VERDICT_INSTRUCTIONS_HARNESS_LEDGER, }; /** @@ -359,5 +371,6 @@ export function buildEvalCatInvocation( legacyCleanup: input.legacyCleanup, sla: domain.sla, }, + ...(input.precomputedEvidence ? { precomputedEvidence: input.precomputedEvidence } : {}), }; } diff --git a/packages/api/src/infrastructure/harness-eval/friction/eval-friction-live-verdict.ts b/packages/api/src/infrastructure/harness-eval/friction/eval-friction-live-verdict.ts index 8c75278f7f..4f47e30057 100644 --- a/packages/api/src/infrastructure/harness-eval/friction/eval-friction-live-verdict.ts +++ b/packages/api/src/infrastructure/harness-eval/friction/eval-friction-live-verdict.ts @@ -50,10 +50,10 @@ export interface FrictionLiveVerdictArtifact { * * Builds the Top-N rollup report from the resolved live input, writes the * task-outcome-shaped bundle (raw report under `bundleDir/raw/`, Decision 2 — - * no extraStagedPaths / no gitignore force-add), resolves canonical bundle refs, + * no extraStagedPaths), resolves canonical bundle refs, * and renders verdict.md from the cat-submitted packet (Decision 3). KD-4: the * generator performs NO writeback (no afterPublish side effect); the only writes - * are verdict.md + bundle inside the publisher's isolated worktree. KD-8: root + * are verdict.md + bundle inside the publisher's artifact staging root. KD-8: root * cause (7-class) is the cat's verdict-layer judgment (carried in the packet's * rootCauseHypothesis), NOT rule-classified here. */ diff --git a/packages/api/src/infrastructure/harness-eval/friction/friction-metrics-provider-impl.ts b/packages/api/src/infrastructure/harness-eval/friction/friction-metrics-provider-impl.ts index a6fe61e380..582882fd39 100644 --- a/packages/api/src/infrastructure/harness-eval/friction/friction-metrics-provider-impl.ts +++ b/packages/api/src/infrastructure/harness-eval/friction/friction-metrics-provider-impl.ts @@ -9,6 +9,7 @@ import { EvalDomainAdapter } from './eval-domain-adapter.js'; import { FrictionAggregator } from './friction-aggregator.js'; import { FrictionClusterer } from './friction-clusterer.js'; import { buildFrictionRollupInput } from './friction-rollup-input.js'; +import { type DeviationQuerySource, GuardAnomalyAdapter } from './guard-anomaly-adapter.js'; import { PawFeelAdapter } from './paw-feel-adapter.js'; import { UserFeedbackAdapter } from './user-feedback-adapter.js'; @@ -39,6 +40,13 @@ export interface FrictionMetricsProviderDeps { /** LIVE docs/harness-feedback root — EvalDomainAdapter scans bundles snapshot.json files. */ harnessFeedbackRoot: string; embeddingService?: IEmbeddingService; + /** + * F257 V2: guard-anomaly channel (5th source). Optional — adapter is + * skipped when the deviation log (Redis) is unavailable. + */ + deviationLog?: DeviationQuerySource; + /** Owner scope for deviation queries (single-user instance: 'default-user'). */ + deviationOwnerUserId?: string; } export class FrictionMetricsProviderImpl implements FrictionMetricsProvider { @@ -52,6 +60,16 @@ export class FrictionMetricsProviderImpl implements FrictionMetricsProvider { new EvalDomainAdapter(this.deps.harnessFeedbackRoot, { excludeFeatureIds: FRICTION_SELF_EXCLUDE_FEATURE_IDS, }), + // F257 V2: 5th channel — cat anomaly reports referencing pot ledgerIds + // (read-only pull per KD-4; AC-B2 stats writeback lives on the write side). + ...(this.deps.deviationLog + ? [ + new GuardAnomalyAdapter({ + deviationLog: this.deps.deviationLog, + ownerUserId: this.deps.deviationOwnerUserId ?? 'default-user', + }), + ] + : []), ]; const aggregator = new FrictionAggregator(sources); // undefined embedding → clusterer fail-opens to rule-only + degraded=true. diff --git a/packages/api/src/infrastructure/harness-eval/friction/guard-anomaly-adapter.ts b/packages/api/src/infrastructure/harness-eval/friction/guard-anomaly-adapter.ts new file mode 100644 index 0000000000..73138ae1cb --- /dev/null +++ b/packages/api/src/infrastructure/harness-eval/friction/guard-anomaly-adapter.ts @@ -0,0 +1,80 @@ +/** + * F257 V2/Phase B — fifth friction source adapter: guard anomaly reports + * (spec Phase B item 3 / AC-B2; F245 aggregation REUSED, no second pipeline). + * + * Data source: DeviationEventLog manual_observation events (V1 T-C, + * written via report_harness_signal) whose note REFERENCES a ledger pot + * coordinate — the ledgerId that rejection responses carry precisely so + * cats can quote it ("撞到 4xx 锅拦截 → anomaly 上报引用 ledger id"). + * + * Extraction is whitelist-exact: only registered GUARD_LEDGER_IDS values + * are matched as substrings (zero false positives; unregistered refs have + * no stats identity to attribute to). condition_hit events are system- + * produced facts, not cat anomaly reports — excluded by design. + * + * KD-4 (F245): the pull path is strictly READ-ONLY — AC-B2 stats writeback + * therefore lives on the WRITE side (report-harness-signal, at the moment + * the anomaly report is recorded), not here. GuardLedgerStats below is the + * shared idempotent stats surface both sides use. + * how_counted: 'scard guard-ledger:stats:{ledgerId}:anomaly-refs — + * distinct referencing deviation eventIds'. + */ + +import type { FrictionSignal } from '@cat-cafe/shared'; +import type { DeviationEvent } from '../deviation/deviation-event.js'; +import { extractLedgerRefs } from '../guard-ledger-registry.js'; +import type { IFrictionSignalSource } from './friction-signal-source.js'; + +/** Minimal query surface this adapter needs from the deviation log. */ +export interface DeviationQuerySource { + query(input: { + ownerUserId: string; + fromMs?: number; + toMs?: number; + cursor?: string; + }): Promise<{ events: DeviationEvent[]; nextCursor: string | null }>; +} + +export interface GuardAnomalyAdapterDeps { + deviationLog: DeviationQuerySource; + ownerUserId: string; +} + +export class GuardAnomalyAdapter implements IFrictionSignalSource { + readonly channelId = 'guard-anomaly' as const; + + constructor(private readonly deps: GuardAnomalyAdapterDeps) {} + + async pull(sinceMs: number, untilMs: number): Promise { + const signals: FrictionSignal[] = []; + let cursor: string | undefined; + do { + const page = await this.deps.deviationLog.query({ + ownerUserId: this.deps.ownerUserId, + fromMs: sinceMs, + // Adapter window is [since, until); deviation query toMs is inclusive. + toMs: untilMs - 1, + ...(cursor ? { cursor } : {}), + }); + for (const event of page.events) { + if (event.kind !== 'manual_observation') continue; + for (const ledgerId of extractLedgerRefs(event.note)) { + signals.push({ + // Deterministic id — same event+pot on every pull (idempotency contract). + id: `guard-anomaly:${event.eventId}#${ledgerId}`, + channel: 'guard-anomaly', + catId: event.subjectCatId, + threadId: event.anchors.threadId, + timestamp: new Date(event.timestamp).toISOString(), + symptom: `anomaly report references pot ${ledgerId}: ${event.note.slice(0, 140)}`, + rawRef: `${event.eventId}#${ledgerId}`, + severity: 'medium', + sourceEvidence: event.note, + }); + } + } + cursor = page.nextCursor ?? undefined; + } while (cursor); + return signals; + } +} diff --git a/packages/api/src/infrastructure/harness-eval/guard-episode-coalescing.ts b/packages/api/src/infrastructure/harness-eval/guard-episode-coalescing.ts new file mode 100644 index 0000000000..610c2252e1 --- /dev/null +++ b/packages/api/src/infrastructure/harness-eval/guard-episode-coalescing.ts @@ -0,0 +1,322 @@ +/** + * F257 V2/Phase B — canonical guard-rejection episode coalescer. + * + * PR #41 verdict (eval:harness-ledger, 2026-07-19-harness-ledger-burst-coalescing-fix-c2): + * four hold_ball 429s within 7.044s were counted as four independent 3-per-7d + * escalation incidents — raw request count is NOT distinct incident count. + * + * This module is the SINGLE coalescing implementation (sol scope ruling, + * msg 0001784468875582): both the real-time threshold path + * (guard-threshold-escalation) and the snapshot/bundle path + * (harness-ledger-snapshot-provider → generator adapter) must call it. + * Two implementations would reintroduce accounting drift. + * + * Contract: + * - Group key: guardId + threadId + catId. ALL THREE must be trusted + * non-empty values — an event with an untrusted key forms its own episode + * and never co-mingles (prevents unknown-identity mis-merges). + * - Stable total order: timestamp asc, tie-broken by eventId asc. + * eventId is the per-raw-rejection coordinate; episodeId is a DERIVED + * coordinate — the two must never be interchanged. + * - Chaining: within a group, an event whose gap to the previous event is + * ≤ EPISODE_GAP_MS extends the current episode (gap-based, matching + * "rapid retry" semantics — a fixed time bucket would split long chains). + * - Deterministic: same input set (any order) → identical episode list, + * so historical windows are replayable and bundles independently + * recheckable. + * + * EPISODE_GAP_MS = 60s covers the known 1s/2s/4s retry backoff envelope + * plus scheduling jitter (sol parameter ruling). V2 deliberately ships NO + * per-guard/operator config surface — revisit only with committed episode + * evidence (V3+). + */ + +import { createHash } from 'node:crypto'; +import type { GuardRejectionEvent } from './GuardRejectionEventLog.js'; +import { HARD_QUERY_CAP } from './guard-rejection-constants.js'; + +/** Adjacent-event gap (ms) at or under which retries chain into one episode. */ +export const EPISODE_GAP_MS = 60_000; + +/** Max anchors carried per episode (first/last always included). */ +const EPISODE_ANCHOR_LIMIT = 3; + +/** Metadata-only pointer to a raw rejection event (no raw payload). */ +export interface EpisodeAnchor { + eventId: string; + kind: string; + guardId: string; + timestamp: number; +} + +/** A coalesced run of rapid same-guard/thread/cat rejections. */ +export interface GuardEpisode { + /** Derived coordinate (deterministic hash) — never a raw eventId. */ + episodeId: string; + guardId: string; + threadId: string; + catId: string; + startMs: number; + endMs: number; + /** Raw rejection events coalesced into this episode (preserved, per verdict). */ + rawEventCount: number; + /** First/last (+1 interior) event anchors — enable independent span recheck. */ + sampleAnchors: EpisodeAnchor[]; +} + +/** A key is trusted only when it is a non-empty, non-placeholder string. */ +function isTrustedKey(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && value !== 'unknown'; +} + +function toAnchor(e: GuardRejectionEvent): EpisodeAnchor { + return { eventId: e.eventId, kind: e.kind, guardId: e.guardId, timestamp: e.timestamp }; +} + +/** Build the episode record from a chronologically sorted event run. */ +function buildEpisode(run: GuardRejectionEvent[]): GuardEpisode { + const first = run[0]; + const last = run[run.length - 1]; + const anchors: EpisodeAnchor[] = + run.length <= EPISODE_ANCHOR_LIMIT ? run.map(toAnchor) : [toAnchor(first), toAnchor(run[1]), toAnchor(last)]; + const episodeId = `ep-${createHash('sha256') + .update(`${first.guardId}|${first.threadId}|${first.catId}|${first.timestamp}|${first.eventId}`) + .digest('hex') + .slice(0, 16)}`; + return { + episodeId, + guardId: first.guardId, + threadId: first.threadId, + catId: first.catId, + startMs: first.timestamp, + endMs: last.timestamp, + rawEventCount: run.length, + sampleAnchors: anchors, + }; +} + +// --------------------------------------------------------------------------- +// Episode boundary tracker — single state machine (Fable ruling) +// --------------------------------------------------------------------------- + +/** Minimum event shape needed by the boundary tracker. */ +interface EpisodeStreamEvent { + guardId: string; + threadId: string; + catId: string; + timestamp: number; +} + +/** Result of feeding one event to the boundary tracker. */ +export interface BoundaryFeedResult { + /** 'solo' = untrusted identity (own episode). 'start' = new run opened. 'extend' = existing run extended. */ + kind: 'solo' | 'start' | 'extend'; + /** Group key (guardId\0threadId\0catId). Undefined for 'solo'. */ + groupKey?: string; + /** True when this event closed a previous run for the same key (gap exceeded). */ + closedPrevious: boolean; +} + +/** + * Episode boundary tracker — the SINGLE state machine for episode + * accounting (Fable ruling: replaces both EpisodeStreamCounter and + * coalescer's independent key/gap/trusted logic). + * + * Tracks open runs by group key and counts closed episodes. Accepts + * events one at a time in timestamp order. `feed()` returns structured + * boundary info so consumers (coalescer, pagewise counter) can react + * without duplicating the key/gap/trusted semantics. + */ +export class EpisodeBoundaryTracker { + private readonly openRunTs = new Map(); + private _closedCount = 0; + + constructor(private readonly gapMs: number = EPISODE_GAP_MS) {} + + /** + * Feed one event (must be in timestamp order within each group key). + * Returns structured boundary info: kind + closedPrevious. + */ + feed(event: EpisodeStreamEvent): BoundaryFeedResult { + const trusted = isTrustedKey(event.guardId) && isTrustedKey(event.threadId) && isTrustedKey(event.catId); + if (!trusted) { + this._closedCount++; + return { kind: 'solo', closedPrevious: false }; + } + const key = `${event.guardId}\0${event.threadId}\0${event.catId}`; + const prevTs = this.openRunTs.get(key); + if (prevTs !== undefined && event.timestamp - prevTs <= this.gapMs) { + this.openRunTs.set(key, event.timestamp); + return { kind: 'extend', groupKey: key, closedPrevious: false }; + } + const closedPrevious = prevTs !== undefined; + if (closedPrevious) this._closedCount++; + this.openRunTs.set(key, event.timestamp); + return { kind: 'start', groupKey: key, closedPrevious }; + } + + get closedCount(): number { + return this._closedCount; + } + + get openRunCount(): number { + return this.openRunTs.size; + } + + /** Lower bound: closed episodes + open runs (each is ≥ 1 episode). */ + get lowerBound(): number { + return this._closedCount + this.openRunTs.size; + } +} + +// --------------------------------------------------------------------------- +// Pagewise streaming counter (Fable ruling: consumes iterateWindow) +// --------------------------------------------------------------------------- + +/** + * Event source for pagewise episode counting — implemented by + * GuardRejectionEventLog (structural typing, no import cycle). + */ +export interface PagewiseEventSource { + iterateWindow( + opts: { since: number; until?: number; guardId?: string; ownerUserId?: string }, + stats?: { pagesFetched: number }, + ): AsyncGenerator; +} + +/** Result of a pagewise threshold check with explicit provenance. */ +export interface PagewiseEpisodeResult { + /** Episode count — exact if `!isLowerBound`, at least this many otherwise. */ + episodeCount: number; + /** True when early-stopped at k OR scan reached hard cap. */ + isLowerBound: boolean; + /** + * Matching events scanned. When `isLowerBound` is true, this is a scan + * lower bound (remaining window events were not fetched), NOT the + * total events in the window. + */ + rawEventsSeen: number; + /** Redis page calls made (the perf metric — should be 1-2 for typical thresholds). */ + pagesFetched: number; + /** Why the count stopped early, if it did. */ + earlyStopReason?: 'threshold_met' | 'hard_cap'; + /** Events observed but excluded by eventFilter (e.g. ineligible skip reasons). */ + skippedByFilter?: number; +} + +/** + * Pagewise streaming episode counter: consumes `iterateWindow` from an + * event source, counting episodes via `EpisodeBoundaryTracker`. Stops + * I/O as soon as `k` episodes are established — returning from the + * `for await` loop terminates the generator, halting further Redis reads. + * + * Counting uses the LOWER BOUND level (closed + open runs), so a new + * distinct-key event immediately contributes without waiting for its + * run to close. + */ +export async function countEpisodesPagewise( + source: PagewiseEventSource, + opts: { since: number; until: number; guardId?: string; ownerUserId: string }, + k: number, + gapMs: number = EPISODE_GAP_MS, + /** + * Optional per-event eligibility filter. Events that fail the filter are + * still counted as `rawEventsSeen` (they were scanned from Redis) but are + * NOT fed to the episode tracker — they don't form or extend episodes. + * + * Use case: skip-reason eligibility (dedup_active events are informational, + * not harmful rejections — they must not contribute to the 3/7d threshold). + * + * Default: all events are eligible (backward compatible). + */ + eventFilter?: (event: GuardRejectionEvent) => boolean, +): Promise { + const stats = { pagesFetched: 0 }; + const tracker = new EpisodeBoundaryTracker(gapMs); + let rawEventsSeen = 0; + let skippedByFilter = 0; + + for await (const event of source.iterateWindow(opts, stats)) { + rawEventsSeen++; + if (rawEventsSeen > HARD_QUERY_CAP) { + return { + episodeCount: Math.min(tracker.lowerBound, k), + isLowerBound: true, + rawEventsSeen, + pagesFetched: stats.pagesFetched, + earlyStopReason: 'hard_cap', + ...(skippedByFilter > 0 ? { skippedByFilter } : {}), + }; + } + // Eligibility filter: non-eligible events are observed but don't form episodes. + if (eventFilter && !eventFilter(event)) { + skippedByFilter++; + continue; + } + tracker.feed(event); + if (tracker.lowerBound >= k) { + return { + episodeCount: k, + isLowerBound: true, + rawEventsSeen, + pagesFetched: stats.pagesFetched, + earlyStopReason: 'threshold_met', + ...(skippedByFilter > 0 ? { skippedByFilter } : {}), + }; + } + } + + return { + episodeCount: tracker.lowerBound, + isLowerBound: false, + rawEventsSeen, + pagesFetched: stats.pagesFetched, + ...(skippedByFilter > 0 ? { skippedByFilter } : {}), + }; +} + +// --------------------------------------------------------------------------- +// Full coalescer (snapshot/bundle path — needs complete episode objects) +// --------------------------------------------------------------------------- + +/** + * Coalesce raw guard-rejection events into distinct episodes. + * + * Pure and deterministic — input order does not affect the output + * (events are stably re-sorted internally; output is ordered by + * startMs asc, episodeId as tie-break). + * + * Delegates to `EpisodeBoundaryTracker` for all key/gap/trusted + * semantics (Fable ruling: single state machine, zero independent logic). + */ +export function coalesceGuardEpisodes(events: GuardRejectionEvent[], gapMs: number = EPISODE_GAP_MS): GuardEpisode[] { + // Stable total order: timestamp asc, tie-break by eventId (per-event coordinate). + const sorted = [...events].sort( + (a, b) => a.timestamp - b.timestamp || (a.eventId < b.eventId ? -1 : a.eventId > b.eventId ? 1 : 0), + ); + + const tracker = new EpisodeBoundaryTracker(gapMs); + const episodes: GuardEpisode[] = []; + /** groupKey → open run of chained events (chronological). */ + const openRuns = new Map(); + + for (const event of sorted) { + const result = tracker.feed(event); + if (result.kind === 'solo') { + episodes.push(buildEpisode([event])); + } else if (result.kind === 'start') { + if (result.closedPrevious) { + episodes.push(buildEpisode(openRuns.get(result.groupKey!)!)); + } + openRuns.set(result.groupKey!, [event]); + } else { + // 'extend' — append to open run + openRuns.get(result.groupKey!)!.push(event); + } + } + for (const run of openRuns.values()) episodes.push(buildEpisode(run)); + + // Deterministic output order independent of grouping traversal. + episodes.sort((a, b) => a.startMs - b.startMs || (a.episodeId < b.episodeId ? -1 : 1)); + return episodes; +} diff --git a/packages/api/src/infrastructure/harness-eval/guard-ledger-registry.ts b/packages/api/src/infrastructure/harness-eval/guard-ledger-registry.ts new file mode 100644 index 0000000000..93ba88889d --- /dev/null +++ b/packages/api/src/infrastructure/harness-eval/guard-ledger-registry.ts @@ -0,0 +1,127 @@ +/** + * F257 V2/Phase B — guard → ledger registry coordinate mapping. + * + * `ledgerId` is the "which pot" coordinate (`{layer}/{slug}`, spec OQ-2): + * per-GUARD, carried in every guard-rejection event AND in the rejection + * response body, so the rejected cat can quote it in an anomaly report + * (F245 fifth friction source adapter → pot stats attribution). + * + * Dual-coordinate contract (V2 ruling): `ledgerId` (pot) and `eventId` + * (per-raw-rejection) are DIFFERENT coordinates and never interchangeable. + * `episodeId` is a third, derived coordinate (see guard-episode-coalescing). + * + * YAML registry files (docs/harness-feedback/ledger/{layer}/{slug}.yaml) + * are a progressive backfill task per spec AC-A1 — this constant map is the + * code-side source of truth until the YAML registry lands. Unregistered + * guards get a fail-visible `unregistered/` prefix instead of a silent + * fallback, so a missing registration shows up in eval verdicts. + */ + +/** + * Known guard → ledger registry coordinates. + * Null-prototype + frozen (sol review P1-3): a plain object literal inherits + * `toString`/`constructor`/`__proto__`, so `guardId in map` and `map[guardId]` + * would accept prototype keys and return FUNCTIONS as ledgerIds. Lookups must + * additionally go through Object.hasOwn (see isRegisteredGuardId). + */ +export const GUARD_LEDGER_IDS: Record = Object.freeze( + Object.assign(Object.create(null) as Record, { + hold_ball_rate_limit: 'mcp/hold-ball-rate-limit', + a2a_block_pingpong: 'mcp/a2a-pingpong-block', + hold_ball_wait_source_ref: 'mcp/hold-ball-wait-source-ref', + cross_post_routing_credentials: 'mcp/cross-post-routing-credentials', + publish_verdict_authority: 'eval/publish-verdict-authority', + a2a_route_decision_skip: 'mcp/a2a-route-decision-skip', + gate_keeping_thread_default: 'mcp/gate-keeping-thread-default', + }), +); + +/** Prototype-safe whitelist membership (sol P1-3: `in` walks the prototype chain). */ +export function isRegisteredGuardId(guardId: string): boolean { + return Object.hasOwn(GUARD_LEDGER_IDS, guardId); +} + +/** + * Reverse whitelist: checks whether a given ledgerId is among the registered + * values (not a guardId key). Prototype-safe (sol P2-2). + * Validates incoming ledgerIds at API query boundaries — rejects spoofed + * pot coordinates that don't map to any known guard. + */ +export function isRegisteredLedgerId(ledgerId: string): boolean { + return Object.values(GUARD_LEDGER_IDS).includes(ledgerId); +} + +/** Resolve a guard's ledger coordinate; unregistered guards are fail-visible. */ +export function ledgerIdForGuard(guardId: string): string { + return Object.hasOwn(GUARD_LEDGER_IDS, guardId) ? GUARD_LEDGER_IDS[guardId] : `unregistered/${guardId}`; +} + +/** Characters that can appear inside a pot coordinate slug. */ +const SLUG_CHAR = /[a-z0-9/-]/; + +/** + * Extract registered pot coordinates referenced in free text. + * Token-boundary matching (sol P2-2): a bare substring test would attribute + * `mcp/hold-ball-rate-limit-evil` (or `xmcp/...`) to the legitimate pot. + * An occurrence counts only when both neighbors are non-slug characters + * (or string edges). + */ +export function extractLedgerRefs(text: string): string[] { + const refs: string[] = []; + for (const ledgerId of Object.values(GUARD_LEDGER_IDS)) { + let from = 0; + while (true) { + const idx = text.indexOf(ledgerId, from); + if (idx < 0) break; + const before = idx > 0 ? (text[idx - 1] as string) : ''; + const after = idx + ledgerId.length < text.length ? (text[idx + ledgerId.length] as string) : ''; + if (!(before && SLUG_CHAR.test(before)) && !(after && SLUG_CHAR.test(after))) { + refs.push(ledgerId); + break; // one ref per pot per note is enough for attribution + } + from = idx + 1; + } + } + return refs; +} + +/** Minimal Redis surface the stats store needs. */ +interface StatsRedis { + sadd(key: string, member: string): Promise; + scard(key: string): Promise; +} + +const STATS_KEY_PREFIX = 'guard-ledger:stats:'; + +/** + * F257 V2 AC-B2 — idempotent per-pot anomaly-reference stats. + * + * Writeback happens on the WRITE side (report-harness-signal, when an + * anomaly report referencing a pot is recorded) — F245 KD-4 keeps the + * friction pull path strictly read-only. SADD of the referencing deviation + * eventId is idempotent, so dedup/replay never double-counts. + * + * how_counted: 'scard guard-ledger:stats:{ledgerId}:anomaly-refs — + * distinct referencing deviation eventIds'. Write fail-open (observation + * loss acceptable), read fail-closed (fake zero misleads operators — sol P2-3). + */ +export class GuardLedgerStats { + constructor(private readonly redis: StatsRedis) {} + + /** Owner-scoped (sol R2 P1: stats must not leak across owners). */ + async recordAnomalyReference(ownerUserId: string, ledgerId: string, deviationEventId: string): Promise { + try { + await this.redis.sadd(`${STATS_KEY_PREFIX}${ownerUserId}:${ledgerId}:anomaly-refs`, deviationEventId); + } catch { + /* fail-open */ + } + } + + /** + * Read-side: propagates Redis errors (sol P2-3 — fake zero hides infra + * failures from operators). Caller must catch and surface `{ available: false }`. + */ + async anomalyReferenceCount(ownerUserId: string, ledgerId: string): Promise { + return await this.redis.scard(`${STATS_KEY_PREFIX}${ownerUserId}:${ledgerId}:anomaly-refs`); + } +} diff --git a/packages/api/src/infrastructure/harness-eval/guard-rejection-constants.ts b/packages/api/src/infrastructure/harness-eval/guard-rejection-constants.ts new file mode 100644 index 0000000000..4e87b2bc5d --- /dev/null +++ b/packages/api/src/infrastructure/harness-eval/guard-rejection-constants.ts @@ -0,0 +1,16 @@ +/** + * F257 — shared Redis constants for guard-rejection event storage. + * + * Single source of truth for the ZSET key and hard cap used by both + * GuardRejectionEventLog (the storage layer) and the pagewise + * threshold counter (the streaming read path). + */ + +/** Redis ZSET key for guard-rejection events: { eventJSON → timestamp }. */ +export const EVENTS_ZSET = 'guard-rejection:events'; + +/** Maximum events before truncation — shared between EventLog and pagewise counter. */ +export const HARD_QUERY_CAP = 10_000; + +/** Redis page size for windowed scans — single source for EventLog + pagewise counter. */ +export const WINDOW_PAGE_SIZE = 1000; diff --git a/packages/api/src/infrastructure/harness-eval/guard-threshold-escalation.ts b/packages/api/src/infrastructure/harness-eval/guard-threshold-escalation.ts new file mode 100644 index 0000000000..d3b1d8de7b --- /dev/null +++ b/packages/api/src/infrastructure/harness-eval/guard-threshold-escalation.ts @@ -0,0 +1,402 @@ +/** + * F257 sub-item 2: Guard threshold escalation — immediate eval trigger. + * + * When a guard accumulates ≥ ESCALATION_THRESHOLD distinct EPISODES within + * ESCALATION_WINDOW_DAYS, triggers an immediate eval:harness-ledger + * invocation instead of waiting for the weekly cron ceiling. + * + * V2/Phase B (PR #41 verdict, burst-coalescing fix): the threshold unit is + * coalesced episodes, not raw events. Rapid same-guard/thread/cat retries + * (adjacent gap ≤ 60s) are ONE incident — see guard-episode-coalescing.ts, + * the canonical coalescer shared with the snapshot/bundle path. + * + * Design decisions: + * - **Event-driven**: hooks into GuardRejectionEventLog.postAppendHook — + * fires on every event append, not on a polling interval. + * - **Dedup via Redis**: a per-guard escalation key with TTL prevents + * re-triggering on the 4th, 5th, … event in the same window. + * Sol R3 P1-1 / Fable ruling: two claim namespaces — confirmed (7d TTL) + * vs uncertainty-probe (1h TTL). Truncation-only claims don't suppress + * real harm. + * - **Fail-open**: escalation failures never affect the business path + * (the hook is already wrapped in try/catch in the event log). + * - **Reuses manual trigger path**: calls handleTriggerNow() to produce + * snapshot → deliver → invoke eval cat (single invocation path, no drift). + */ + +import type { RedisClient } from '@cat-cafe/shared/utils'; +import type { GuardRejectionEvent } from './GuardRejectionEventLog.js'; +import { countEpisodesPagewise, type PagewiseEventSource } from './guard-episode-coalescing.js'; +import type { TriggerNowInput, TriggerNowSkipped, TriggerNowSuccess } from './manual-trigger/trigger-now.js'; +import type { HandlerError } from './manual-trigger/types.js'; +import { isEscalationEligible } from './skip-reason-eligibility.js'; + +/** Narrowed result type matching handleTriggerNow's return union. */ +export type TriggerEvalResult = TriggerNowSuccess | TriggerNowSkipped | HandlerError; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** + * Minimum distinct episodes for a single guard to trigger immediate eval. + * Unit is EPISODES (coalesced incidents), not raw events — PR #41 verdict. + */ +export const ESCALATION_THRESHOLD = 3; + +/** Window in days over which events are counted toward the threshold. */ +export const ESCALATION_WINDOW_DAYS = 7; + +/** Redis key prefix for per-guard confirmed-harm escalation dedup (7d TTL). */ +const DEDUP_KEY_PREFIX = 'guard-rejection:escalated:'; + +/** Redis key prefix for uncertainty-probe claims (Fable ruling: separate namespace). */ +const UNCERTAINTY_KEY_PREFIX = 'guard-rejection:uncertainty:'; + +/** Dedup TTL matches the escalation window so keys auto-expire. */ +const DEDUP_TTL_SECONDS = ESCALATION_WINDOW_DAYS * 24 * 3600; + +/** + * Sol R3 P1-1 / Fable ruling: TTL for uncertainty-probe claims. + * 1 hour — matches hold_ball window magnitude (Fable parameter ruling). + * Prevents eval storm from consecutive cap events (NX blocks within window) + * but auto-expires so confirmed threshold claims are never suppressed. + */ +export const UNCERTAINTY_PROBE_TTL_SECONDS = 3600; + +// --------------------------------------------------------------------------- +// Escalation result (for testing / observability) +// --------------------------------------------------------------------------- + +export interface EscalationCheckResult { + checked: true; + guardId: string; + /** Backward-compat alias of rawEventCount (pre-episode consumers). */ + count: number; + /** + * Raw rejection events scanned. When `rawEventCountIsLowerBound` is true, + * this is events seen before early-stop, NOT the total window count. + */ + rawEventCount: number; + /** True when pagewise scan early-stopped — rawEventCount is a scan lower bound. */ + rawEventCountIsLowerBound?: boolean; + /** + * Coalesced distinct episodes in window. When `episodeCountIsLowerBound` + * is true, this is at-least-k (early-stopped or hard-cap hit), NOT exact. + */ + episodeCount: number; + /** True when episodeCount is a lower bound (early-stop or hard cap). */ + episodeCountIsLowerBound?: boolean; + /** Redis pages fetched (perf metric — threshold check should be 1-2). */ + pagesFetched?: number; + /** sol R2 P2: window hit the hard cap — counts are lower bounds; thresholdMet is conservative-true. */ + truncated?: boolean; + /** + * Sol R3 P1-1 / Fable ruling: escalation kind distinguishes confirmed + * (episodeCount ≥ threshold) from uncertainty_probe (truncation-only). + * Probe claims use a short-TTL separate key that doesn't block future + * confirmed escalations. Present only when thresholdMet is true. + */ + escalationKind?: 'confirmed' | 'uncertainty_probe'; + thresholdMet: boolean; + alreadyEscalated: boolean; + escalated: boolean; + /** Claim won but trigger failed → claim released so next event can retry. */ + claimReleased?: boolean; + triggerResult?: TriggerEvalResult; +} + +// --------------------------------------------------------------------------- +// Dependencies +// --------------------------------------------------------------------------- + +export interface GuardThresholdEscalationDeps { + redis: RedisClient; + /** Event source for pagewise episode counting (Fable ruling: restore EventLog dep). */ + guardRejectionLog: PagewiseEventSource; + /** + * Trigger function — typically a partial application of handleTriggerNow + * with all deps pre-bound. Returns narrowed result so we can distinguish + * success (keep 7d claim) from failure (release claim for retry). + */ + triggerEval: (input: TriggerNowInput) => Promise; +} + +// --------------------------------------------------------------------------- +// Claim lifecycle helper +// --------------------------------------------------------------------------- + +/** + * Attempt to release escalation claim so the next event can retry. + * Returns `true` only when DEL succeeds. On DEL failure, the 7-day TTL + * backstop auto-expires the claim — bounded degradation, not permanent. + */ +async function releaseClaim(redis: RedisClient, dedupKey: string, guardId: string): Promise { + try { + await redis.del(dedupKey); + return true; + } catch (err) { + console.warn(`[F257] escalation claim DEL failed for guard=${guardId}, 7d TTL backstop active`, err); + return false; + } +} + +// --------------------------------------------------------------------------- +// Core function +// --------------------------------------------------------------------------- + +/** + * Check whether a guard has crossed the escalation threshold and, if so, + * trigger an immediate eval:harness-ledger invocation. + * + * Deduplication: a Redis key `guard-rejection:escalated:` with + * TTL = ESCALATION_WINDOW_DAYS prevents re-escalation on subsequent events + * from the same guard within the same window. + * + * @returns Result indicating what happened (for tests/observability). + */ +export async function checkGuardThreshold( + event: GuardRejectionEvent, + deps: GuardThresholdEscalationDeps, +): Promise { + const { guardId } = event; + const windowMs = ESCALATION_WINDOW_DAYS * 24 * 3600 * 1000; + const since = event.timestamp - windowMs; + + // Step 1: pagewise streaming episode count (sol R5 P2-1). + // Pages through Redis directly, counting episodes as events arrive in + // timestamp order. Stops I/O once ESCALATION_THRESHOLD episodes are found — + // a 10k-event window typically resolves in 1-2 page calls for k=3. + // +1 because the query uses half-open [since, until) interval + // (upperBound = until - 1). Without +1 the just-appended event at event.timestamp + // is excluded and the threshold fires one episode late. + // + // Sol verdict 2026-07-21 (dedup_active false-escalation): eligibility filter + // excludes informational skip reasons (e.g. dedup_active) from episode counting. + // Events are still scanned (rawEventsSeen) but don't form episodes. Unknown + // reasons default to eligible (fail-closed — new reasons escalate until classified). + const pagewiseResult = await countEpisodesPagewise( + deps.guardRejectionLog, + { since, until: event.timestamp + 1, guardId, ownerUserId: event.ownerUserId }, + ESCALATION_THRESHOLD, + undefined, // gapMs — use default + (e) => isEscalationEligible(e.normalizedReason), + ); + const { episodeCount, isLowerBound, rawEventsSeen: rawEventCount, pagesFetched } = pagewiseResult; + const truncated = pagewiseResult.earlyStopReason === 'hard_cap'; + if (truncated) { + console.warn(`[F257] escalation window truncated at hard cap for guard=${guardId}; episodeCount is a lower bound`); + } + // Sol R2 P1-1: truncation = incomplete scan → always conservative-true. + // The eligibility filter correctly excludes informational events (e.g. + // dedup_active) from episode counting in the SCANNED portion, but + // truncation means the unscanned tail may contain eligible episodes. + // A mixed window (10k dedup_active then 3 depth) would produce + // episodeCount=0 with skippedByFilter>0 — the R1 approach of + // `!skippedByFilter` silently chose false-negative for that case. + // Conservative-true on truncation: false positive (one eval run where + // eval cat sees all-informational byReason) is bounded and acceptable; + // false negative (missed harmful pattern in tail) is a safety gap. + const meetsThreshold = episodeCount >= ESCALATION_THRESHOLD || truncated; + if (!meetsThreshold) { + return { + checked: true, + guardId, + count: rawEventCount, + rawEventCount, + ...(isLowerBound ? { rawEventCountIsLowerBound: true } : {}), + episodeCount, + ...(isLowerBound ? { episodeCountIsLowerBound: true } : {}), + ...(pagesFetched ? { pagesFetched } : {}), + ...(truncated ? { truncated } : {}), + thresholdMet: false, + alreadyEscalated: false, + escalated: false, + }; + } + + // Step 2: atomic claim via SET NX EX — only one concurrent caller wins. + // Pattern: ApiInstanceLease / RedisDeliveryDedup / RedisProposalStore (codebase prior art). + // NX = set-if-not-exists; EX = TTL in seconds. + // + // Sol R3 P1-1: separate claim lifecycle for confirmed vs uncertain escalation. + // Problem: truncation-only conservative-true claimed the 7d dedup key, + // suppressing subsequent real 3×depth episodes for the entire window. + // Fix: two claim namespaces with different TTLs. + // + // Confirmed (episodeCount ≥ threshold): 7d TTL on primary key — prevents + // redundant eval for an already-identified harmful pattern. + // Uncertainty-probe (truncated, episodeCount < threshold): 1h TTL on + // separate key (Fable ruling: matches hold_ball window magnitude) — + // prevents eval storm from consecutive cap events but does NOT block + // future confirmed escalations (different key namespace). + // + // Sol R3 constraints: + // 1. dedup-only cap → one uncertain eval (short-TTL claim fires trigger) ✓ + // 2. Subsequent 3 real eligible episodes → second trigger (different key) ✓ + // 3. Consecutive cap events → no eval storm (uncertainty_probe NX blocks within 1h) ✓ + // 4. Only confirmed eligible threshold → 7d claim ✓ + const isConfirmed = episodeCount >= ESCALATION_THRESHOLD; + const escalationKind = isConfirmed ? ('confirmed' as const) : ('uncertainty_probe' as const); + const confirmedDedupKey = `${DEDUP_KEY_PREFIX}${event.ownerUserId}:${guardId}`; + let dedupKey: string; + let claimTtl: number; + + if (isConfirmed) { + dedupKey = confirmedDedupKey; + claimTtl = DEDUP_TTL_SECONDS; + } else { + // Uncertain path: if confirmed key already exists, real harm was already + // escalated — uncertain eval is redundant. GET is non-atomic with the + // subsequent SET, but harmless: worst case is one extra uncertain eval + // if a confirmed claim races in between. + const existingConfirmed = await deps.redis.get(confirmedDedupKey); + if (existingConfirmed !== null) { + return { + checked: true, + guardId, + count: rawEventCount, + rawEventCount, + ...(isLowerBound ? { rawEventCountIsLowerBound: true } : {}), + episodeCount, + ...(isLowerBound ? { episodeCountIsLowerBound: true } : {}), + ...(pagesFetched ? { pagesFetched } : {}), + ...(truncated ? { truncated } : {}), + escalationKind, + thresholdMet: true, + alreadyEscalated: true, + escalated: false, + }; + } + dedupKey = `${UNCERTAINTY_KEY_PREFIX}${event.ownerUserId}:${guardId}`; + claimTtl = UNCERTAINTY_PROBE_TTL_SECONDS; + } + + const claimValue = JSON.stringify({ + escalatedAt: event.timestamp, + count: rawEventCount, + ...(isLowerBound ? { rawEventCountIsLowerBound: true } : {}), + episodeCount, + ...(isLowerBound ? { episodeCountIsLowerBound: true } : {}), + triggeredBy: event.eventId, + escalationKind, + }); + const claimed = await deps.redis.set(dedupKey, claimValue, 'EX', claimTtl, 'NX'); + if (claimed !== 'OK') { + // Another concurrent caller already claimed — dedup. + return { + checked: true, + guardId, + count: rawEventCount, + rawEventCount, + ...(isLowerBound ? { rawEventCountIsLowerBound: true } : {}), + episodeCount, + ...(isLowerBound ? { episodeCountIsLowerBound: true } : {}), + ...(pagesFetched ? { pagesFetched } : {}), + ...(truncated ? { truncated } : {}), + escalationKind, + thresholdMet: true, + alreadyEscalated: true, + escalated: false, + }; + } + + // Step 3: trigger eval:harness-ledger via the manual trigger path. + // Invariant: ALL paths that don't confirm dispatch attempt to release claim. + // handleTriggerNow can fail two ways: + // a) resolved 503/skipped (non-throw) — checked in Step 4 + // b) reject/throw (transport error, Redis inside handler, messageStore.append) + // Both must release the claim to prevent 7-day silent suppression. + let triggerResult: TriggerEvalResult | undefined; + try { + triggerResult = await deps.triggerEval({ + domainId: 'eval:harness-ledger', + userId: event.ownerUserId, + // Sol R1 P2-1: server-injected source thread (Fable ruling). + sourceThreadId: event.threadId, + // Sol R4 P1-1 / Fable ruling: propagate escalation kind to snapshot + bundle. + escalationKind, + }); + } catch { + // triggerEval rejected — release claim so next event can retry. + const released = await releaseClaim(deps.redis, dedupKey, guardId); + return { + checked: true, + guardId, + count: rawEventCount, + rawEventCount, + ...(isLowerBound ? { rawEventCountIsLowerBound: true } : {}), + episodeCount, + ...(isLowerBound ? { episodeCountIsLowerBound: true } : {}), + ...(pagesFetched ? { pagesFetched } : {}), + ...(truncated ? { truncated } : {}), + escalationKind, + thresholdMet: true, + alreadyEscalated: false, + escalated: false, + claimReleased: released, + }; + } + + // Step 4: verify trigger actually dispatched (dispatched/enqueued). + // Only { ok: true, invocationTriggered: true } confirms eval cat was invoked. + const dispatched = 'ok' in triggerResult && triggerResult.ok === true && 'invocationTriggered' in triggerResult; + + if (!dispatched) { + const released = await releaseClaim(deps.redis, dedupKey, guardId); + return { + checked: true, + guardId, + count: rawEventCount, + rawEventCount, + ...(isLowerBound ? { rawEventCountIsLowerBound: true } : {}), + episodeCount, + ...(isLowerBound ? { episodeCountIsLowerBound: true } : {}), + ...(pagesFetched ? { pagesFetched } : {}), + ...(truncated ? { truncated } : {}), + escalationKind, + thresholdMet: true, + alreadyEscalated: false, + escalated: false, + claimReleased: released, + triggerResult, + }; + } + + return { + checked: true, + guardId, + count: rawEventCount, + rawEventCount, + ...(isLowerBound ? { rawEventCountIsLowerBound: true } : {}), + episodeCount, + ...(isLowerBound ? { episodeCountIsLowerBound: true } : {}), + ...(pagesFetched ? { pagesFetched } : {}), + ...(truncated ? { truncated } : {}), + escalationKind, + thresholdMet: true, + alreadyEscalated: false, + escalated: true, + triggerResult, + }; +} + +// --------------------------------------------------------------------------- +// Hook factory — creates the postAppendHook for GuardRejectionEventLog +// --------------------------------------------------------------------------- + +/** + * Create a post-append hook that checks guard thresholds on every event. + * Wire this into GuardRejectionEventLog.setPostAppendHook() at bootstrap. + * + * The hook is fire-and-forget: starts the async check but doesn't await it + * (the event log's append path must not block on escalation). + */ +export function createThresholdEscalationHook( + deps: GuardThresholdEscalationDeps, +): (event: GuardRejectionEvent) => void { + return (event: GuardRejectionEvent) => { + // Fire-and-forget — errors are swallowed by the event log's try/catch. + void checkGuardThreshold(event, deps).catch(() => {}); + }; +} diff --git a/packages/api/src/infrastructure/harness-eval/harness-ledger-snapshot-provider.ts b/packages/api/src/infrastructure/harness-eval/harness-ledger-snapshot-provider.ts new file mode 100644 index 0000000000..9daad05439 --- /dev/null +++ b/packages/api/src/infrastructure/harness-eval/harness-ledger-snapshot-provider.ts @@ -0,0 +1,298 @@ +/** + * F257 Harness Ledger — run snapshot provider (KD-17 snapshot-first pattern). + * + * Produces a normalized guard-rejection-event snapshot BEFORE the eval cat + * is invoked. The eval cat receives the snapshot summary in its invocation + * content (evidence-first, not blind). The generator adapter later reads + * the SAME stored snapshot file (single-read by evalRunId, no re-query). + * + * Invariant: trigger produces → eval cat reads summary → generator reads + * stored snapshot. Decision and artifact share one data source. Drift = 0. + * + * Storage: `harness-feedback/run-snapshots/.json` + * Fail-closed: Redis error in queryWindowStrict propagates (no false zero). + */ + +import { randomBytes } from 'node:crypto'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { GuardRejectionEventLog } from './GuardRejectionEventLog.js'; +import { coalesceGuardEpisodes, type GuardEpisode } from './guard-episode-coalescing.js'; +import { isEscalationEligible, skipReasonCategory } from './skip-reason-eligibility.js'; + +/** Normalized per-guard aggregate in the stored snapshot. */ +export interface GuardAggregate { + /** Raw rejection events (preserved per PR #41 verdict). */ + count: number; + kinds: string[]; + /** Coalesced distinct episodes — the incident count (PR #41 verdict). */ + episodeCount: number; + /** Episode metadata with per-episode anchors for independent recheck. */ + episodes: GuardEpisode[]; +} + +/** Shape of the stored run snapshot (persisted JSON). */ +export interface HarnessLedgerRunSnapshot { + evalRunId: string; + producedAt: string; + /** + * Owner scope — the snapshot is scoped to this single owner (sol R9 P1-2). + * Persisted so the evidence package self-documents its scope. + */ + ownerUserId: string; + window: { + startMs: number; + endMs: number; + durationHours: number; + }; + totalEvents: number; + byKind: Record; + /** Per-guard aggregate — generator uses this for attribution findings. */ + byGuard: Record; + /** First N events for provenance anchoring (no raw payload, metadata only). */ + sampleAnchors: Array<{ + eventId: string; + kind: string; + guardId: string; + timestamp: number; + }>; + /** how_counted — judgment schema v1 §2 alignment. */ + howCounted: 'zset-window-scan'; + /** + * sol P2-1: true when the window hit the hard query cap — counts are lower + * bounds and the eval verdict must flag incompleteness explicitly. + */ + truncated: boolean; + /** + * Sol R1 P2-1: per-reason breakdown with eligibility and category. + * Self-documents which skip reasons contributed to the snapshot — + * a bundle can prove "all 3 events were dedup_active" without external + * reasoning. Optional for backward compat with pre-classification snapshots. + */ + byReason?: Record; + /** + * Sol R1 P2-1: server-injected thread coordinate of the trigger source. + * Escalation path: event.threadId. Manual trigger: invocation thread. + * NOT self-reported by eval cat — Fable ruling: owner-scope discipline. + * Optional: scheduled triggers have no specific source thread. + */ + sourceThreadId?: string; + /** + * Sol R4 P1-1 / Fable ruling: escalation kind provenance. + * 'confirmed' = episodeCount ≥ threshold. 'uncertainty_probe' = truncation-only. + * Absent for manual/scheduled triggers (not escalation-driven). + */ + escalationKind?: 'confirmed' | 'uncertainty_probe'; +} + +export interface ProduceSnapshotDeps { + guardRejectionLog: GuardRejectionEventLog; + harnessFeedbackRoot: string; + /** + * Owner scope (sol R9 P1-2): REQUIRED — snapshots MUST be scoped to a single + * owner. The event contract defines ownerUserId as the read isolation boundary; + * unscoped queries would mix events across owners, corrupting verdicts. + * Manual trigger: use input.userId; scheduled: use config.defaultUserId. + */ + ownerUserId: string; + /** Override window duration (default: 7 days). */ + windowMs?: number; + /** + * Server-injected source thread coordinate (sol R1 P2-1). + * Escalation: event.threadId. Manual trigger: invocation thread. + * Scheduled: undefined (no specific source thread). + */ + sourceThreadId?: string; + /** + * Sol R4 P1-1: escalation kind from threshold check. + * Propagated to snapshot for bundle provenance. + */ + escalationKind?: 'confirmed' | 'uncertainty_probe'; +} + +export interface ProduceSnapshotResult { + evalRunId: string; + storagePath: string; + snapshot: HarnessLedgerRunSnapshot; + /** Human-readable summary for eval invocation injection. */ + summary: string; + /** + * Raw guard events from queryWindowStrict (full threadId/catId). + * Exposed for judgment engine per-event correlation (±120s window join). + * Not persisted in the snapshot file — transient in-process only. + */ + rawEvents: Array<{ eventId: string; guardId: string; threadId: string; catId: string; timestamp: number }>; +} + +const DEFAULT_WINDOW_MS = 7 * 24 * 3600 * 1000; +const SAMPLE_ANCHOR_LIMIT = 5; + +export async function produceHarnessLedgerRunSnapshot(deps: ProduceSnapshotDeps): Promise { + // sol R10 supplementary: TypeScript `string` alone is insufficient — empty string + // passes type check but skips iterateWindow's ownerUserId filter (truthy guard), + // silently producing an unscoped snapshot. Fail-closed at runtime. + if (!deps.ownerUserId) { + throw new Error( + 'harness_ledger_snapshot_owner_required: ownerUserId must be a non-empty string. ' + + 'Empty/missing owner scope would produce an unscoped snapshot (sol R9 P1-2 violation).', + ); + } + const evalRunId = `hlr-${Date.now()}-${randomBytes(4).toString('hex')}`; + const windowMs = deps.windowMs ?? DEFAULT_WINDOW_MS; + const now = Date.now(); + const windowStartMs = now - windowMs; + + // Fail-closed: queryWindowStrictComplete propagates Redis errors. + // Completeness-preserving (sol P2-1): the old default-200 slice silently + // dropped events; `truncated` is surfaced in the snapshot so eval verdicts + // can flag incomplete windows instead of asserting over partial data. + // sol R9 P1-2: scoped to ownerUserId — never mix events across owners. + const { events, truncated } = await deps.guardRejectionLog.queryWindowStrictComplete({ + since: windowStartMs, + until: now, + ownerUserId: deps.ownerUserId, + }); + + // Aggregate by kind + const byKind: Record = {}; + for (const e of events) { + byKind[e.kind] = (byKind[e.kind] ?? 0) + 1; + } + + // Aggregate by guard (with kinds per guard — generator needs this for attribution) + const byGuard: Record = {}; + for (const e of events) { + const existing = byGuard[e.guardId]; + if (existing) { + existing.count += 1; + if (!existing.kinds.includes(e.kind)) existing.kinds.push(e.kind); + } else { + byGuard[e.guardId] = { count: 1, kinds: [e.kind], episodeCount: 0, episodes: [] }; + } + } + + // Coalesce episodes via the canonical coalescer (PR #41 verdict) — the SAME + // implementation the real-time threshold path uses, so decision and artifact + // can never drift on accounting. + for (const episode of coalesceGuardEpisodes(events)) { + const agg = byGuard[episode.guardId]; + if (agg) { + agg.episodeCount += 1; + agg.episodes.push(episode); + } + } + + // Sol R1 P2-1: per-reason breakdown with eligibility classification. + // Self-documents "3 events were dedup_active (ineligible)" in the snapshot + // so bundle can prove the claim without external reasoning. + // Sol R2 P1-2: null-prototype object prevents callback-controlled reason + // strings (e.g. "__proto__", "constructor") from polluting Object.prototype. + const byReason = Object.create(null) as Record; + for (const e of events) { + const reason = e.normalizedReason ?? 'unspecified'; + const existing = byReason[reason]; + if (existing) { + existing.count += 1; + } else { + byReason[reason] = { + count: 1, + category: skipReasonCategory(reason), + eligible: isEscalationEligible(reason), + }; + } + } + + const snapshot: HarnessLedgerRunSnapshot = { + evalRunId, + producedAt: new Date().toISOString(), + ownerUserId: deps.ownerUserId, + window: { + startMs: windowStartMs, + endMs: now, + durationHours: Math.round(windowMs / (3600 * 1000)), + }, + totalEvents: events.length, + byKind, + byGuard, + byReason, + ...(deps.sourceThreadId ? { sourceThreadId: deps.sourceThreadId } : {}), + ...(deps.escalationKind ? { escalationKind: deps.escalationKind } : {}), + sampleAnchors: events.slice(0, SAMPLE_ANCHOR_LIMIT).map((e) => ({ + eventId: e.eventId, + kind: e.kind, + guardId: e.guardId, + timestamp: e.timestamp, + })), + howCounted: 'zset-window-scan', + truncated, + }; + + // Persist snapshot to filesystem (generator reads by evalRunId). + const dir = join(deps.harnessFeedbackRoot, 'run-snapshots'); + mkdirSync(dir, { recursive: true }); + const storagePath = join(dir, `${evalRunId}.json`); + writeFileSync(storagePath, JSON.stringify(snapshot, null, 2)); + + const summary = buildSnapshotSummary(snapshot, byGuard, windowStartMs, now, evalRunId, truncated, events.length); + + // Expose raw events for judgment engine (per-event correlation, not persisted). + const rawEvents = events.map((e) => ({ + eventId: e.eventId, + guardId: e.guardId, + threadId: e.threadId, + catId: e.catId, + timestamp: e.timestamp, + })); + + return { evalRunId, storagePath, snapshot, summary, rawEvents }; +} + +// ── Extracted helper (sol R11 P3-1: reduce cognitive complexity of main fn) ── + +/** Build human-readable summary for eval cat injection (KD-17 last-hop). */ +function buildSnapshotSummary( + snapshot: HarnessLedgerRunSnapshot, + byGuard: Record, + windowStartMs: number, + windowEndMs: number, + evalRunId: string, + truncated: boolean, + eventCount: number, +): string { + const guardSummary = Object.entries(byGuard) + .map( + (entry) => + ` - ${entry[0]}: ${entry[1].count} raw event(s) / ${entry[1].episodeCount} episode(s) [${entry[1].kinds.join(', ')}]`, + ) + .join('\n'); + // Provide exact sourceRefs JSON so eval cat copies raw values + // (no ISO→epoch conversion that could drift by 1ms and trigger window_mismatch). + const exactSourceRefs = { + kind: 'prompt-segments' as const, + windowStartMs, + windowEndMs, + evalRunId, + }; + return [ + `### Pre-computed Guard Rejection Snapshot (evalRunId: ${evalRunId})`, + '', + `- **Window**: ${snapshot.window.durationHours}h [${new Date(windowStartMs).toISOString()} → ${new Date(windowEndMs).toISOString()})`, + `- **Total events**: ${eventCount}`, + ...(truncated + ? ['- ⚠️ **WINDOW TRUNCATED at hard cap** — all counts below are LOWER BOUNDS; flag incompleteness in the verdict'] + : []), + ...(snapshot.escalationKind === 'uncertainty_probe' + ? [ + '- ⚠️ **UNCERTAINTY PROBE** — this eval was triggered by truncation (incomplete scan), NOT confirmed harmful threshold. byReason only covers the capped portion; the unscanned tail is unknown.', + ] + : []), + eventCount > 0 + ? `- **By guard**:\n${guardSummary}` + : '- No guard rejection events in this window (baseline accumulation phase)', + '', + '**Copy this exact sourceRefs when publishing** (do NOT modify values or convert formats):', + '```json', + JSON.stringify(exactSourceRefs, null, 2), + '```', + ].join('\n'); +} diff --git a/packages/api/src/infrastructure/harness-eval/hub/eval-hub-read-model.ts b/packages/api/src/infrastructure/harness-eval/hub/eval-hub-read-model.ts index 4e9df44412..0cf52fd13d 100644 --- a/packages/api/src/infrastructure/harness-eval/hub/eval-hub-read-model.ts +++ b/packages/api/src/infrastructure/harness-eval/hub/eval-hub-read-model.ts @@ -23,6 +23,14 @@ type CountRecord = Record; export interface LoadEvalHubSummaryInput { harnessFeedbackRoot: string; + /** + * F257 / F192 sunset: optional durable artifact store root where + * ArtifactPublisher commits verdict bundles (outside the product Git repo). + * When provided, live verdicts are loaded from both the legacy in-repo + * `verdicts/` directory AND the artifact store; artifact-store entries take + * precedence for the same verdict id. + */ + artifactStoreRoot?: string; /** * Wall-clock reference for staleness checks. Defaults to `new Date()`. * Injectable so date-dependent regression tests don't drift over time. @@ -130,15 +138,45 @@ export interface EvalHubItem { friction?: EvalHubFrictionProjection; } +type VerdictEntry = { + verdict: ParsedVerdictMarkdown; + bundleDir: string; + verdictPath: string; +}; + export function loadEvalHubSummary(input: LoadEvalHubSummaryInput): EvalHubSummary { const verdictsDir = join(input.harnessFeedbackRoot, 'verdicts'); const domains = loadDomains(input.harnessFeedbackRoot); const now = input.now ?? new Date(); - const items = readdirSync(verdictsDir, { withFileTypes: true }) - .filter((entry) => entry.isFile() && entry.name.endsWith('.md')) - .map((entry) => parseVerdictMarkdown(join(verdictsDir, entry.name))) - .filter((verdict) => verdict.frontmatter.feedback_type === 'live-verdict') - .map((verdict) => buildEvalHubItem(input.harnessFeedbackRoot, verdict, domains, now)) + const repoRoot = dirname(dirname(input.harnessFeedbackRoot)); + + let entries: VerdictEntry[] = existsSync(verdictsDir) + ? readdirSync(verdictsDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.md')) + .map((entry) => { + const verdictPath = join(verdictsDir, entry.name); + const verdict = parseVerdictMarkdown(verdictPath); + return { + verdict, + verdictPath, + bundleDir: join(input.harnessFeedbackRoot, 'bundles', verdict.id), + }; + }) + : []; + + // F257 / F192 sunset: durable artifact-store verdicts take precedence over + // legacy in-repo verdicts for the same id. Load artifacts first, then backfill + // legacy entries only for ids not present in the artifact store. + const artifactEntries = + input.artifactStoreRoot && existsSync(input.artifactStoreRoot) + ? loadArtifactStoreVerdicts(input.artifactStoreRoot) + : []; + const artifactIds = new Set(artifactEntries.map((e) => e.verdict.id)); + entries = [...artifactEntries, ...entries.filter((legacyEntry) => !artifactIds.has(legacyEntry.verdict.id))]; + + const items = entries + .filter((entry) => entry.verdict.frontmatter.feedback_type === 'live-verdict') + .map((entry) => buildEvalHubItem(input.harnessFeedbackRoot, entry.verdict, entry.bundleDir, domains, now, repoRoot)) .sort((a, b) => b.trend.generatedAt.localeCompare(a.trend.generatedAt)); // F192 P2 — supersede gating (PR 791 review). @@ -194,15 +232,67 @@ export function loadEvalHubSummary(input: LoadEvalHubSummaryInput): EvalHubSumma }; } +/** + * F257 / F192 sunset: scan durable artifact store for verdicts committed by + * ArtifactPublisher. Each artifact lives at `///`. + * + * Two internal layouts are supported: + * - Canonical: `/verdict.md` + `/bundle/` + * - Generator-native: `/docs/harness-feedback/verdicts/.md` + * + `/docs/harness-feedback/bundles//`. This matches + * the legacy isolated-worktree layout existing generators expect, so the + * publisher can commit the worktree verbatim without renames. + */ +function loadArtifactStoreVerdicts(artifactStoreRoot: string): VerdictEntry[] { + const entries: VerdictEntry[] = []; + if (!existsSync(artifactStoreRoot)) return entries; + + for (const domainEntry of readdirSync(artifactStoreRoot, { withFileTypes: true })) { + if (!domainEntry.isDirectory()) continue; + const domainDir = join(artifactStoreRoot, domainEntry.name); + for (const artifactEntry of readdirSync(domainDir, { withFileTypes: true })) { + if (!artifactEntry.isDirectory()) continue; + const artifactDir = join(domainDir, artifactEntry.name); + const artifactId = artifactEntry.name; + + // Canonical layout + let verdictPath = join(artifactDir, 'verdict.md'); + let bundleDir = join(artifactDir, 'bundle'); + + // Generator-native isolated-worktree layout + const nativeVerdictDir = join(artifactDir, 'docs', 'harness-feedback', 'verdicts'); + const nativeVerdictPath = join(nativeVerdictDir, `${artifactId}.md`); + const nativeBundleDir = join(artifactDir, 'docs', 'harness-feedback', 'bundles', artifactId); + if (!existsSync(verdictPath) && existsSync(nativeVerdictPath)) { + verdictPath = nativeVerdictPath; + bundleDir = existsSync(nativeBundleDir) ? nativeBundleDir : nativeBundleDir; + } + + if (!existsSync(verdictPath)) continue; + const verdict = parseVerdictMarkdown(verdictPath); + // Artifact store filenames are either `verdict.md` or `.md`; + // the artifact id is the directory name. Override the file-derived id so + // bundle resolution and Hub item ids match the artifact. + verdict.id = artifactId; + entries.push({ + verdict, + bundleDir, + verdictPath, + }); + } + } + return entries; +} + function buildEvalHubItem( harnessFeedbackRoot: string, verdict: ParsedVerdictMarkdown, + bundleDir: string, domains: Map, now: Date, + repoRoot: string, ): EvalHubItem { const verdictId = verdict.id; - const bundleDir = join(harnessFeedbackRoot, 'bundles', verdictId); - const repoRoot = dirname(dirname(harnessFeedbackRoot)); let resolved: ReturnType; try { resolved = resolveA2aEvidenceBundle({ bundleDir, verdictId }); diff --git a/packages/api/src/infrastructure/harness-eval/manual-trigger/generate-now.ts b/packages/api/src/infrastructure/harness-eval/manual-trigger/generate-now.ts index 49fa020f93..f3bd1c099c 100644 --- a/packages/api/src/infrastructure/harness-eval/manual-trigger/generate-now.ts +++ b/packages/api/src/infrastructure/harness-eval/manual-trigger/generate-now.ts @@ -1,188 +1,32 @@ -import { existsSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { generateA2aLiveVerdict } from '../a2a/eval-a2a-live-verdict.js'; -import { loadDomains, loadEvalHubSummary } from '../hub/eval-hub-read-model.js'; -import { resolveSafeRawPath } from '../safe-path.js'; import type { HandlerError, ManualTriggerDeps } from './types.js'; -// Cloud codex R10 P1 + 砚砚收敛 A: length limits for user-supplied fields. -// verdictId becomes a filename (`.md`) + directory (`bundles//`); -// artifact basenames resolve to allowlist dir entries. Conservative POSIX -// basename limit (255) for artifact names; verdictId tighter to keep slugs -// human-readable + URL-safe in Hub UI. -const MAX_VERDICT_ID_LEN = 128; -const MAX_ARTIFACT_NAME_LEN = 255; - +/** + * Compatibility shape for the retired manual endpoint. Its former implementation + * wrote verdict evidence directly into the product checkout, which violated the + * runtime-data boundary and caused execution data to enter Git. Callers must use + * `cat_cafe_publish_verdict`, backed by ArtifactPublisher, instead. + */ export interface GenerateNowInput { domainId: string; userId: string; verdictId?: string; - /** - * Basename of the raw snapshot YAML inside `/snapshots/`. - * MUST be a plain filename — no path separators, no `.` / `..`. Resolved - * server-side under allowlist directory before any filesystem read - * (砚砚 R1 P1: never accept arbitrary paths from session API). - */ snapshotName?: string; - /** - * Basename of the raw attribution YAML inside `/attributions/`. - * Same allowlist constraints as `snapshotName`. - */ attributionName?: string; } -export interface GenerateNowSuccess { - ok: true; - domainId: string; - verdictId: string; - verdictPath: string; - bundleDir: string; - hubRoundtrip: { ok: boolean; itemCount: number }; - note: string; -} - -// resolveSafeRawPath extracted to ../safe-path.ts and shared with publish-verdict. - /** - * F192 OQ-21: Manually generate a live verdict for eval:a2a using existing - * `generateA2aLiveVerdict` (PR #1856). Writes verdict.md + bundle/ to - * `docs/harness-feedback/` and verifies roundtrip through `loadEvalHubSummary()`. - * - * Unsupported domains (memory/sop/task-outcome/capability-wakeup) return 501 - * — NOT a stub `keep_observe`. 砚砚 directive: 低质量 keep_observe 比无报告更坏 - * (污染 Eval Hub 信任). Other domains gain generators in Path B+. - * - * Generator writes to working tree only. For permanent SOT, artifacts must be - * committed via PR/merge-gate (砚砚: 未 commit ≠ 长期 SOT). + * F192/F257 sunset: fail closed before reading evidence or touching the checkout. + * The stable 410 response keeps old clients diagnosable without preserving the + * unsafe product-worktree writer. */ -export async function handleGenerateNow( - deps: Pick, - input: GenerateNowInput, -): Promise { - // 砚砚 R1 P2-a: validate domain via registry FIRST — unknown = 400, NOT 501. - // Without this, typo'd domainIds get falsely labeled "unsupported_generator". - const domains = loadDomains(deps.harnessFeedbackRoot); - const domain = domains.get(input.domainId as Parameters[0]); - if (!domain) { - return { status: 400, error: `Domain '${input.domainId}' not registered in eval-domains/` }; - } - - // Registered but no live-verdict generator wired in v1 → 501. - // 砚砚 P1 (R0): NO stub. 低质量 keep_observe 污染 Eval Hub 信任. - if (input.domainId !== 'eval:a2a') { - return { - status: 501, - error: 'unsupported_generator', - detail: `Domain '${input.domainId}' is registered but has no live-verdict generator wired. Only eval:a2a in v1 (F192 OQ-21). Other domains (memory/sop/capability-wakeup/task-outcome) gain generators in Path B+.`, - }; - } - - // Cloud codex R3 P2: validate body field types BEFORE reaching basename()/resolve(). - // Non-strings would hit `node:path.basename()` → TypeError → Fastify 500. - const isNonEmptyString = (v: unknown): v is string => typeof v === 'string' && v.length > 0; - if ( - !isNonEmptyString(input.verdictId) || - !isNonEmptyString(input.snapshotName) || - !isNonEmptyString(input.attributionName) - ) { - return { - status: 400, - error: 'verdictId, snapshotName, attributionName must all be non-empty strings for eval:a2a generate-now', - }; - } - - // Cloud codex R4 P2: validate verdictId slug format BEFORE calling generator. - // Generator throws "verdictId must be a safe slug" for uppercase/underscores/ - // leading-hyphen — currently caught and returned as 500 ("Generator failed"). - // These are deterministic bad-requests, surface as 400 with the actual pattern. - // Mirrors SAFE_VERDICT_ID_PATTERN in eval-a2a-live-verdict.ts. - const SAFE_VERDICT_ID = /^[a-z0-9][a-z0-9-]*$/; - if (!SAFE_VERDICT_ID.test(input.verdictId)) { - return { - status: 400, - error: `verdictId must match safe slug pattern /^[a-z0-9][a-z0-9-]*$/ (lowercase alphanumeric + hyphens, no leading hyphen). Got: '${input.verdictId}'`, - }; - } - - // 砚砚 R10 收敛 A: length limits — prevent DoS via huge inputs and keep - // verdict files / bundle dirs within filesystem limits. - if (input.verdictId.length > MAX_VERDICT_ID_LEN) { - return { - status: 400, - error: `verdictId must be <= ${MAX_VERDICT_ID_LEN} chars (got ${input.verdictId.length})`, - }; - } - if (input.snapshotName.length > MAX_ARTIFACT_NAME_LEN) { - return { - status: 400, - error: `snapshotName must be <= ${MAX_ARTIFACT_NAME_LEN} chars (got ${input.snapshotName.length})`, - }; - } - if (input.attributionName.length > MAX_ARTIFACT_NAME_LEN) { - return { - status: 400, - error: `attributionName must be <= ${MAX_ARTIFACT_NAME_LEN} chars (got ${input.attributionName.length})`, - }; - } - - // 砚砚 R1 P1 (security): resolve user-supplied basenames under allowlist directories - // BEFORE calling generator. Previously the route accepted raw paths and forwarded - // them to readFileSync — any authenticated session could read arbitrary local files. - // Raw artifacts live in `/snapshots/` + `/attributions/` per OQ-15. - const snapshotsDir = resolve(deps.harnessFeedbackRoot, 'snapshots'); - const attributionsDir = resolve(deps.harnessFeedbackRoot, 'attributions'); - - const snapshotResult = resolveSafeRawPath(snapshotsDir, input.snapshotName); - if (!snapshotResult.ok) { - return { status: 400, error: `snapshotName invalid: ${snapshotResult.reason}` }; - } - - const attributionResult = resolveSafeRawPath(attributionsDir, input.attributionName); - if (!attributionResult.ok) { - return { status: 400, error: `attributionName invalid: ${attributionResult.reason}` }; - } - - // Cloud codex R10 P1 + 砚砚收敛 A: idempotency guard — generator uses plain - // writeFileSync on `verdicts/.md` and `bundles//*.json` and would - // silently overwrite existing Eval Hub evidence on duplicate verdictId. - // This is evidence-chain data corruption, NOT a v1.5 polish. Reject with 409 - // BEFORE invoking the generator so prior verdict + bundle remain intact. - const verdictPath = resolve(deps.harnessFeedbackRoot, 'verdicts', `${input.verdictId}.md`); - const bundleDir = resolve(deps.harnessFeedbackRoot, 'bundles', input.verdictId); - if (existsSync(verdictPath) || existsSync(bundleDir)) { - return { - status: 409, - error: 'verdict_already_exists', - detail: `verdictId '${input.verdictId}' already has a verdict file or bundle directory under docs/harness-feedback/. Overwriting existing Eval Hub evidence is forbidden (data integrity). Pick a different verdictId or delete the existing artifacts first.`, - }; - } - - let artifact: ReturnType; - try { - artifact = generateA2aLiveVerdict({ - verdictId: input.verdictId, - rawSnapshotPath: snapshotResult.path, - rawAttributionPath: attributionResult.path, - harnessFeedbackRoot: deps.harnessFeedbackRoot, - domain, - }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { status: 500, error: 'Generator failed', detail: message }; - } - - // Roundtrip — verify hub read model includes the new verdict (砚砚 R0 P1 e2e). - // Match by verdictId (hub item.id = basename, no .md) to avoid Mac /tmp symlink issues. - const summary = loadEvalHubSummary({ harnessFeedbackRoot: deps.harnessFeedbackRoot }); - const found = summary.items.find((item) => item.id === input.verdictId); - - return { - ok: true, - domainId: 'eval:a2a', - verdictId: input.verdictId, - verdictPath: artifact.path, - bundleDir: artifact.bundleDir, - hubRoundtrip: { ok: Boolean(found), itemCount: summary.items.length }, - note: 'Generated to working tree. For permanent SOT, commit + push via PR/merge-gate. Verdict will NOT appear in deployed Eval Hub until committed to main.', - }; +export function handleGenerateNow( + _deps: Pick, + _input: GenerateNowInput, +): Promise { + return Promise.resolve({ + status: 410, + error: 'generate_now_sunset', + detail: + 'The legacy generate-now endpoint was retired because it wrote runtime verdict evidence into the product Git checkout. Use cat_cafe_publish_verdict; it publishes to the durable artifact store and does not create Git commits, branches, or PRs.', + }); } diff --git a/packages/api/src/infrastructure/harness-eval/manual-trigger/trigger-now-judgments.ts b/packages/api/src/infrastructure/harness-eval/manual-trigger/trigger-now-judgments.ts new file mode 100644 index 0000000000..3b4f549928 --- /dev/null +++ b/packages/api/src/infrastructure/harness-eval/manual-trigger/trigger-now-judgments.ts @@ -0,0 +1,87 @@ +/** + * F257 Judgment integration helper for trigger-now + eval-domain-daily. + * + * Bridges ProduceSnapshotResult → produceSegmentJudgments → formatted evidence string. + * Extracted to avoid pushing trigger-now/eval-domain-daily over 350 lines. + */ + +import type { InjectionTraceStore } from '../../../domains/prompt-hooks/InjectionTraceStore.js'; +import type { ProduceSnapshotResult } from '../harness-ledger-snapshot-provider.js'; +import { produceSegmentJudgments, type SegmentJudgment } from '../segment-judgment-engine.js'; + +/** + * Produce segment judgments from a snapshot result. + * + * Extracts threadIds from raw events, passes rawGuardEvents for per-event + * ±120s window correlation (not limited to 5-event sampleAnchors). + */ +export async function produceJudgmentsFromSnapshot( + traceStore: InjectionTraceStore, + snapshotResult: ProduceSnapshotResult, + evalCat: string, +): Promise { + // Extract unique threadIds from raw events + const threadIdSet = new Set(); + for (const e of snapshotResult.rawEvents) { + if (e.threadId) threadIdSet.add(e.threadId); + } + const threadIds = [...threadIdSet]; + + if (threadIds.length === 0) return []; + + return produceSegmentJudgments( + { traceStore }, + { + snapshot: snapshotResult.snapshot, + evalCat, + threadIds, + rawGuardEvents: snapshotResult.rawEvents, + }, + ); +} + +/** + * Format judgments as human-readable evidence for eval cat injection. + * + * Eval cat receives this as part of precomputedEvidence (after snapshot summary). + * Format: markdown table for quick scanning + JSON detail for machine consumption. + */ +export function formatJudgmentsForEvidence(judgments: SegmentJudgment[]): string { + const alive = judgments.filter((j) => j.verdict === 'alive'); + const unmeasurable = judgments.filter((j) => j.verdict === 'unmeasurable'); + + const lines = [ + '### Per-Segment Judgments (deterministic, judgment-schema-v1)', + '', + `- **Total segments**: ${judgments.length}`, + `- **Alive** (denominator > 0): ${alive.length}`, + `- **Unmeasurable** (no denominator): ${unmeasurable.length}`, + '', + ]; + + if (alive.length > 0) { + lines.push('**Alive segments** (injectionCount / violationCount):'); + for (const j of alive) { + const ic = j.evidence.injectionCount.value; + const vc = j.evidence.violationCount.value; + const rate = ic > 0 ? `${((vc / ic) * 100).toFixed(1)}%` : 'n/a'; + lines.push(` - \`${j.segmentId}\`: ${ic} injections, ${vc} violations (rate: ${rate})`); + } + lines.push(''); + } + + if (unmeasurable.length > 0) { + lines.push('**Unmeasurable segments** (no fired injections in window):'); + for (const j of unmeasurable) { + lines.push(` - \`${j.segmentId}\``); + } + lines.push(''); + } + + // Machine-readable detail for generator adapter consumption + lines.push('```json'); + lines.push(JSON.stringify(judgments, null, 2)); + lines.push('```'); + + return lines.join('\n'); +} diff --git a/packages/api/src/infrastructure/harness-eval/manual-trigger/trigger-now.ts b/packages/api/src/infrastructure/harness-eval/manual-trigger/trigger-now.ts index b0e079bd80..d5fcd88d7e 100644 --- a/packages/api/src/infrastructure/harness-eval/manual-trigger/trigger-now.ts +++ b/packages/api/src/infrastructure/harness-eval/manual-trigger/trigger-now.ts @@ -1,13 +1,31 @@ import { getEvalCatOverride } from '../domain/eval-domain-override.js'; import type { EvalDomainId } from '../domain/eval-domain-registry.js'; import { buildEvalCatInvocation } from '../eval-cat-invocation.js'; +import { produceHarnessLedgerRunSnapshot } from '../harness-ledger-snapshot-provider.js'; import { loadDomains } from '../hub/eval-hub-read-model.js'; import { ensureEvalDomainThreads } from '../hub/eval-hub-thread-ensure.js'; +import { formatJudgmentsForEvidence, produceJudgmentsFromSnapshot } from './trigger-now-judgments.js'; import type { HandlerError, ManualTriggerDeps } from './types.js'; export interface TriggerNowInput { domainId: string; userId: string; + /** + * Sol R1 P2-1: server-injected source thread coordinate. + * Escalation: event.threadId (the thread where the guard rejection fired). + * Manual trigger: invocation thread. Scheduled: undefined. + * Fable ruling: must NOT be self-reported by eval cat — owner-scope discipline. + */ + sourceThreadId?: string; + /** + * Sol R4 P1-1 / Fable ruling: escalation kind provenance. + * 'confirmed' = episodeCount ≥ threshold (real eligible harm). + * 'uncertainty_probe' = truncation-only conservative-true (incomplete scan). + * Propagated to snapshot + bundle so eval cat knows probe's byReason + * only covers the capped scan, not the full window. + * Manual/scheduled triggers: undefined (not escalation-driven). + */ + escalationKind?: 'confirmed' | 'uncertainty_probe'; } export interface TriggerNowSuccess { @@ -24,6 +42,21 @@ export interface TriggerNowSuccess { triggerOutcome: 'dispatched' | 'enqueued'; } +/** + * F257 sub-item 1: Zero-event skip result. + * Snapshot produced successfully but contains zero guard rejection events + * in the observation window. Eval cat NOT invoked (LLM cost = 0). + * This is a valid state, not an error. + */ +export interface TriggerNowSkipped { + ok: true; + domainId: string; + skipped: true; + reason: 'zero_events_in_window'; + evalRunId: string; + windowSummary: string; +} + /** * F192 OQ-21: Manual eval trigger — true wake via late-bound invokeTrigger. * @@ -38,7 +71,7 @@ export interface TriggerNowSuccess { export async function handleTriggerNow( deps: ManualTriggerDeps, input: TriggerNowInput, -): Promise { +): Promise { const domains = loadDomains(deps.harnessFeedbackRoot); const domain = domains.get(input.domainId as Parameters[0]); if (!domain) { @@ -93,12 +126,71 @@ export async function handleTriggerNow( } } + // KD-17 snapshot-first: for eval:harness-ledger, snapshot is REQUIRED. + // No snapshot → 503 (fail-closed for manual trigger). + let precomputedEvidence: string | undefined; + if (input.domainId === 'eval:harness-ledger') { + if (!deps.guardRejectionLog) { + return { + status: 503, + error: 'harness_ledger_snapshot_unavailable', + detail: + 'KD-17: eval:harness-ledger requires GuardRejectionEventLog provider for snapshot-first invocation. Provider not wired at runtime.', + }; + } + try { + const snapshotResult = await produceHarnessLedgerRunSnapshot({ + guardRejectionLog: deps.guardRejectionLog, + harnessFeedbackRoot: deps.harnessFeedbackRoot, + ownerUserId: input.userId, + sourceThreadId: input.sourceThreadId, + escalationKind: input.escalationKind, + }); + precomputedEvidence = snapshotResult.summary; + + // F257: Produce per-segment judgments (deterministic, no LLM). + if (deps.traceStore) { + const judgments = await produceJudgmentsFromSnapshot( + deps.traceStore, + snapshotResult, + effectiveDomain.evalCat.catId, + ); + if (judgments.length > 0) { + precomputedEvidence += `\n\n${formatJudgmentsForEvidence(judgments)}`; + // F257 Phase D: persist latest judgments for lifeline API consumption + await deps.judgmentCache?.updateBatch(judgments); + } + } + + // F257 sub-item 1: Zero events → skip (valid state, no data to evaluate). + // Snapshot OK but empty window — eval cat has nothing to attribute. + if (snapshotResult.snapshot.totalEvents === 0) { + return { + ok: true as const, + domainId: input.domainId, + skipped: true as const, + reason: 'zero_events_in_window' as const, + evalRunId: snapshotResult.evalRunId, + windowSummary: `${snapshotResult.snapshot.window.durationHours}h window, 0 events`, + }; + } + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + return { + status: 503, + error: 'harness_ledger_snapshot_failed', + detail: `KD-17: snapshot production failed — ${detail}. Eval cat not invoked (no blind verdicts).`, + }; + } + } + const invocation = buildEvalCatInvocation( { domain: effectiveDomain, trendRefs: [], verdictRefs: [], legacyCleanup: { status: 'not_checked' }, + precomputedEvidence, }, // cloud R5 P2 (PR-2): gate publish instructions on actual runtime support so // cats don't waste a run producing a packet they can't publish (501 from @@ -108,7 +200,7 @@ export async function handleTriggerNow( }, ); - const content = [ + const contentParts = [ `## Eval Domain: ${invocation.domainId} (manual trigger by ${input.userId})`, '', invocation.instructions, @@ -116,9 +208,15 @@ export async function handleTriggerNow( '```json', JSON.stringify(invocation.context, null, 2), '```', - ].join('\n'); + ]; + // KD-17: inject pre-computed evidence after context JSON + if (invocation.precomputedEvidence) { + contentParts.push('', invocation.precomputedEvidence); + } + const content = contentParts.join('\n'); const stored = await deps.messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, // sol R3 P1-1 userId: 'scheduler', catId: null, content, diff --git a/packages/api/src/infrastructure/harness-eval/manual-trigger/types.ts b/packages/api/src/infrastructure/harness-eval/manual-trigger/types.ts index 2b5424c26f..756372d157 100644 --- a/packages/api/src/infrastructure/harness-eval/manual-trigger/types.ts +++ b/packages/api/src/infrastructure/harness-eval/manual-trigger/types.ts @@ -1,6 +1,8 @@ import type { Redis } from 'ioredis'; import type { IMessageStore } from '../../../domains/cats/services/stores/ports/MessageStore.js'; import type { IThreadStore } from '../../../domains/cats/services/stores/ports/ThreadStore.js'; +import type { InjectionTraceStore } from '../../../domains/prompt-hooks/InjectionTraceStore.js'; +import type { GuardRejectionEventLog } from '../GuardRejectionEventLog.js'; /** * F192 OQ-21 — Shared types for manual eval trigger handlers. @@ -50,6 +52,24 @@ export interface ManualTriggerDeps { * legacy default (all known-wireable domains get publish instructions). */ wiredPublishDomains?: ReadonlySet; + /** + * KD-17 snapshot-first: GuardRejectionEventLog for eval:harness-ledger + * pre-invocation snapshot production. Optional — when absent, harness-ledger + * trigger skips snapshot injection (eval cat gets instructions only). + */ + guardRejectionLog?: GuardRejectionEventLog; + /** + * F257 judgment engine: InjectionTraceStore for per-segment injection counting. + * When provided, segment judgments are produced and appended to eval evidence. + * Optional — when absent, eval cat gets snapshot evidence only (no judgments). + */ + traceStore?: InjectionTraceStore; + /** + * F257 Phase D: SegmentJudgmentCache for persisting latest judgment results. + * When provided, judgment results are cached after production for lifeline API. + * Optional — when absent, judgments are only included in eval evidence text. + */ + judgmentCache?: import('../../../domains/prompt-hooks/SegmentJudgmentCache.js').SegmentJudgmentCache; } export interface HandlerError { diff --git a/packages/api/src/infrastructure/harness-eval/memory/eval-memory-live-verdict.ts b/packages/api/src/infrastructure/harness-eval/memory/eval-memory-live-verdict.ts index 051f6bb450..f813b8517e 100644 --- a/packages/api/src/infrastructure/harness-eval/memory/eval-memory-live-verdict.ts +++ b/packages/api/src/infrastructure/harness-eval/memory/eval-memory-live-verdict.ts @@ -17,8 +17,8 @@ import { assertMemorySubmittedPacket } from './memory-submitted-packet-guard.js' * 2. Build snapshot.json + attribution.json from cat-submitted packet + resolved metrics * 3. Write raw inputs (recall-metrics.json + library-health.json) outside bundle * at `/generated/memory//` — referenced by provenance.json - * sha256; publisher MUST stage this dir via extraStagedPaths or auto-PR loses - * replayable evidence. + * sha256; the publisher persists the entire artifact staging root so these + * replay inputs remain auditable with the bundle. * 4. Resolve evidence bundle refs (snapshot + attribution names) * 5. Render verdict.md with packet + resolved refs * @@ -50,8 +50,8 @@ export interface MemoryLiveVerdictArtifact { /** * Replayed raw inputs (`recall-metrics.json` + `library-health.json`) live OUTSIDE * `bundleDir` at `/generated/memory//`. `provenance.json` (inside - * bundleDir) references them by relative path + sha256. Publisher MUST stage this dir - * via extraStagedPaths or auto-PR omits replayable inputs. + * bundleDir) references them by relative path + sha256. The durable artifact + * includes this directory together with the verdict bundle. */ rawInputDir: string; packet: VerdictHandoffPacket; diff --git a/packages/api/src/infrastructure/harness-eval/objective-registry.ts b/packages/api/src/infrastructure/harness-eval/objective-registry.ts new file mode 100644 index 0000000000..d5223bdf85 --- /dev/null +++ b/packages/api/src/infrastructure/harness-eval/objective-registry.ts @@ -0,0 +1,130 @@ +/** + * F257 修复清单 #3 — Objective registry loader (definition layer). + * + * Reads `docs/harness-feedback/objectives/registry.yaml` — the read-only + * discovery source for `report_harness_signal`'s `objectiveId`, so cats stop + * doing archaeology to find valid objectives ("三次上报三次考古"). KD-3: + * registry 定义层用 YAML;运行时 stats 拆到 Redis/eval. + * + * This is the canonical objective DEFINITION layer only: `id + statement`. + * Unit→objective membership (which segments an objective is evaluated over) is + * NOT authored here — per the frozen redesign §4.8 it lives in the versioned + * `UnitEvaluationManifest.objectives` keyed by typed unitRef, and a derived + * read-model can expose attachments in V2. Keeping a second writable authority + * out of this file avoids the dual-source drift sol flagged (2a R1 P1-1). + * + * Fail-closed (2a R1 P1-2): a load/parse/validation failure is reported as an + * explicit error, NEVER collapsed to a valid-but-empty catalog — a silent empty + * would recreate the archaeology gap it exists to close. + */ + +import { readFile } from 'node:fs/promises'; +import { parse as parseYaml } from 'yaml'; + +export interface ObjectiveDefinition { + id: string; + statement: string; +} + +export interface ObjectiveRegistry { + registryVersion: number; + objectives: ObjectiveDefinition[]; +} + +/** Discriminated result: a valid registry, or an explicit failure reason. */ +export type ObjectiveRegistryResult = { ok: true; registry: ObjectiveRegistry } | { ok: false; error: string }; + +/** Canonical objective id shape: `obj-` + kebab-case (lowercase alnum groups). */ +const OBJECTIVE_ID_RE = /^obj-[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** The only registry schema version this loader implements (2a R3 P2-1). */ +const SUPPORTED_REGISTRY_VERSION = 1; + +function fail(error: string): ObjectiveRegistryResult { + return { ok: false, error }; +} + +/** Validate a single objective row → definition, or an error string. */ +function validateObjective(entry: unknown, index: number, seen: Set): ObjectiveDefinition | string { + if (!entry || typeof entry !== 'object') return `objectives[${index}] is not a mapping`; + // 2a R2 P2-1: fail-closed on unknown keys (registryVersion=1 allows only id/statement). + // Rejecting rather than stripping keeps the file the single authority — a stray + // `segments`/typo/未版本化 field can't silently reappear and mislead a human reader. + for (const key of Object.keys(entry)) { + if (key !== 'id' && key !== 'statement') { + const hint = + key === 'segments' + ? ' — unit→objective membership belongs to the versioned UnitEvaluationManifest (§4.8), not this definition registry' + : ' (registryVersion=1 allows only id/statement; bump the schema to add fields)'; + return `objectives[${index}] has unsupported key "${key}"${hint}`; + } + } + const o = entry as { id?: unknown; statement?: unknown }; + if (typeof o.id !== 'string' || o.id.trim() !== o.id || o.id.length === 0) { + return `objectives[${index}].id must be a trimmed non-empty string`; + } + if (!OBJECTIVE_ID_RE.test(o.id)) return `objectives[${index}].id "${o.id}" must match ${OBJECTIVE_ID_RE.source}`; + if (typeof o.statement !== 'string' || o.statement.trim().length === 0) { + return `objectives[${o.id}].statement must be a non-empty string`; + } + if (seen.has(o.id)) return `duplicate objective id "${o.id}"`; + seen.add(o.id); + return { id: o.id, statement: o.statement.trim() }; +} + +/** + * Parse + strictly validate the objective registry YAML. Pure (no I/O) so it is + * unit-testable. Returns an explicit failure (ok:false) on malformed YAML, a + * non-mapping root, a non-positive-integer version, a missing/non-array + * `objectives`, any invalid row, or a duplicate id — never a silent empty. + */ +export function parseObjectiveRegistry(rawYaml: string): ObjectiveRegistryResult { + let doc: unknown; + try { + doc = parseYaml(rawYaml); + } catch (err) { + return fail(`malformed registry YAML: ${err instanceof Error ? err.message : String(err)}`); + } + if (!doc || typeof doc !== 'object') return fail('registry root must be a mapping'); + // 2a R2 P2-1: fail-closed on unknown root keys (registryVersion=1 allows only + // registryVersion/objectives). Future fields must bump the schema, not slip through. + for (const key of Object.keys(doc)) { + if (key !== 'registryVersion' && key !== 'objectives') { + return fail(`unknown registry key "${key}" (registryVersion=1 allows only registryVersion/objectives)`); + } + } + + const record = doc as { registryVersion?: unknown; objectives?: unknown }; + const version = record.registryVersion; + // 2a R3 P2-1: this loader implements ONLY v1 semantics, so accept exactly v1. A future + // schema must ship a versioned parser + bump this — advertising an unimplemented version + // (2, 999, …) as supported to discovery clients is the inconsistency being closed. + if (version !== SUPPORTED_REGISTRY_VERSION) { + return fail(`registryVersion must be exactly ${SUPPORTED_REGISTRY_VERSION} (this loader implements only v1)`); + } + if (!Array.isArray(record.objectives)) return fail('registry `objectives` must be an array'); + + const objectives: ObjectiveDefinition[] = []; + const seen = new Set(); + for (let i = 0; i < record.objectives.length; i++) { + const result = validateObjective(record.objectives[i], i, seen); + if (typeof result === 'string') return fail(result); + objectives.push(result); + } + return { ok: true, registry: { registryVersion: version, objectives } }; +} + +/** + * Load + validate the objective registry from disk. Returns an explicit failure + * (ok:false) when the file is unreadable — the caller (route/tool) must surface + * it (503/error), not present it as an empty catalog. + */ +export async function loadObjectiveRegistry(registryPath: string): Promise { + let raw: string; + try { + raw = await readFile(registryPath, 'utf-8'); + } catch (err) { + return fail(`registry unreadable at ${registryPath}: ${err instanceof Error ? err.message : String(err)}`); + } + return parseObjectiveRegistry(raw); +} diff --git a/packages/api/src/infrastructure/harness-eval/publish-verdict/a2a-generator-adapter.ts b/packages/api/src/infrastructure/harness-eval/publish-verdict/a2a-generator-adapter.ts index ec09295e5d..ad3b604e65 100644 --- a/packages/api/src/infrastructure/harness-eval/publish-verdict/a2a-generator-adapter.ts +++ b/packages/api/src/infrastructure/harness-eval/publish-verdict/a2a-generator-adapter.ts @@ -52,7 +52,7 @@ export function createA2aGeneratorAdapter(): VerdictGenerator { copyFileSync(liveRefs.refs.snapshotPath, isoSnapPath); copyFileSync(liveRefs.refs.attributionPath, isoAttrPath); - // Load domain entry from registry inside the isolated worktree's harness root. + // Load domain entry from the temporary artifact harness root. const domains = loadDomains(deps.harnessFeedbackRoot); const domain = domains.get(packet.domainId); if (!domain) throw new Error(`unknown_domain: ${packet.domainId} not in registry`); diff --git a/packages/api/src/infrastructure/harness-eval/publish-verdict/capability-wakeup-generator-adapter.ts b/packages/api/src/infrastructure/harness-eval/publish-verdict/capability-wakeup-generator-adapter.ts index afa9e5361f..e4f3c317f8 100644 --- a/packages/api/src/infrastructure/harness-eval/publish-verdict/capability-wakeup-generator-adapter.ts +++ b/packages/api/src/infrastructure/harness-eval/publish-verdict/capability-wakeup-generator-adapter.ts @@ -18,8 +18,7 @@ import type { VerdictGenerator } from './types.js'; * 2. validateCapabilityWakeupSelector (PR-1a's structural validator — * capability non-empty, no newlines, window edges finite + ordered, etc.) * 3. provider.resolve(selector) → ClassifiedCapabilityWakeupTrial[] - * 4. Load EvalDomainRegistryEntry from registry inside isolated harness root - * (registry is on origin/main, included in isolated worktree) + * 4. Load EvalDomainRegistryEntry from the temporary artifact harness root * 5. generateCapabilityWakeupLiveVerdict with submittedPacket (砚砚 R8 P1: cat * owns verdict; tool only overrides bundle refs) * @@ -73,14 +72,8 @@ export function createCapabilityWakeupGeneratorAdapter(provider: CapabilityWakeu // PR-2 R3 P1 (cloud): cw generator writes `trials.json` + `summary.json` at // `/generated/capability-wakeup//` (referenced by - // provenance.json with sha256). Publisher MUST stage this dir or auto-PR - // omits raw inputs and reviewers/main can't audit/replay the verdict. - // - // NOTE: `generated/capability-wakeup/` is .gitignored (.gitignore:209). The - // FIX for that lives in `git-worktree-publisher.ts:71` (`git add -f --`) — - // cloud R4/R5 keep flagging this line as if the fix should be here, but the - // gitignore force-add is the publisher's responsibility. See R4 commit - // `51c49c847` and R4 P1 comment in git-worktree-publisher.ts:66-70. + // provenance.json with sha256). The ArtifactPublisher persists the whole + // staging root outside the product Git repository, including this directory. return { verdictPath: artifact.path, bundleDir: artifact.bundleDir, diff --git a/packages/api/src/infrastructure/harness-eval/publish-verdict/error-mapping.ts b/packages/api/src/infrastructure/harness-eval/publish-verdict/error-mapping.ts index 6aef17e290..aa0c8432c2 100644 --- a/packages/api/src/infrastructure/harness-eval/publish-verdict/error-mapping.ts +++ b/packages/api/src/infrastructure/harness-eval/publish-verdict/error-mapping.ts @@ -4,6 +4,9 @@ export function mapPublishVerdictError(message: string): HandlerError | null { if (message.startsWith('verdict_already_exists_on_main')) { return { status: 409, error: 'verdict_already_exists', detail: message }; } + if (message.startsWith('artifact_already_exists')) { + return { status: 409, error: 'verdict_already_exists', detail: message }; + } if (message.startsWith('invalid_source_ref')) { return { status: 400, error: 'invalid_source_ref', detail: message }; } diff --git a/packages/api/src/infrastructure/harness-eval/publish-verdict/git-worktree-publisher.ts b/packages/api/src/infrastructure/harness-eval/publish-verdict/git-worktree-publisher.ts deleted file mode 100644 index 5600ea0516..0000000000 --- a/packages/api/src/infrastructure/harness-eval/publish-verdict/git-worktree-publisher.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { execFile } from 'node:child_process'; -import { mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { resolve } from 'node:path'; -import { promisify } from 'node:util'; -import { withHiddenGhCliWindow } from '../../github/gh-cli-env.js'; -import type { GitPublisher, PublishOnIsolatedWorktreeOpts } from './publish-verdict.js'; - -const exec = promisify(execFile); - -/** - * F192 Phase H — Real GitPublisher impl using `git worktree add` + `gh pr create`. - * - * Creates an isolated worktree from `origin/main`, runs the caller's `stage` - * callback inside it (which calls the verdict generator), commits the - * generated artifacts to a NEW branch, pushes it to `origin`, and opens an - * auto-PR via `gh`. The isolated worktree is removed in a `finally` block so - * neither success nor failure pollutes the live worktree. - * - * 砚砚 R1 P1 #1: handler's live `harnessFeedbackRoot` is never mutated by this - * impl — all writes go through the isolated worktree. - * - * 砚砚 R1 P2 #2 (race protection): `git worktree add -b ` fails - * atomically if the branch already exists, surfacing as - * `git_or_gh_failed: fatal: A branch named ... already exists`. - */ -export interface GitWorktreePublisherDeps { - /** Repo root the API server is running in (must be a git checkout with `origin`). */ - repoRoot: string; -} - -export function createGitWorktreePublisher(deps: GitWorktreePublisherDeps): GitPublisher { - return { - async publishOnIsolatedWorktree(opts: PublishOnIsolatedWorktreeOpts) { - // Use mkdtemp to get a guaranteed-unique path; suffix with PID for debuggability - const worktreePath = mkdtempSync(`${tmpdir()}/cat-cafe-publish-verdict-${process.pid}-`); - - // 砚砚 R4 P2 cloud: track whether PR was opened so failure cleanup can - // delete the local branch (worktree add -b creates branch + worktree; - // worktree remove only removes worktree, leaving branch behind for - // retries to hit "branch already exists" race). - let prOpened = false; - let pushSucceeded = false; - let prUrl: string | null = null; - let branchExistedBefore = false; - - try { - // 1. Fetch latest origin/main to ensure isolated worktree is current - await exec('git', ['-C', deps.repoRoot, 'fetch', 'origin', 'main'], { timeout: 60_000 }); - - // Probe upfront so partial-failure cleanup never deletes a pre-existing branch. - try { - await exec('git', ['-C', deps.repoRoot, 'rev-parse', '--verify', `refs/heads/${opts.branchName}`], { - timeout: 10_000, - }); - branchExistedBefore = true; - } catch { - branchExistedBefore = false; - } - - // 2. Create isolated worktree on a new branch from origin/main - // Atomic: fails if branch already exists (race protection) - await exec( - 'git', - ['-C', deps.repoRoot, 'worktree', 'add', '-b', opts.branchName, worktreePath, opts.sourceBase], - { timeout: 60_000 }, - ); - - // 3. Run caller's stage callback (generator writes verdict artifacts) - const { paths, commitMessage, prTitle, prBody, labels, afterPublish } = await opts.stage(worktreePath); - - if (paths.length === 0) { - throw new Error('stage produced no paths to commit'); - } - - // 4. Add + commit artifacts inside isolated worktree - // Convert absolute paths to repo-relative so `git add` works inside worktree - const relativePaths = paths.map((p) => { - const rel = resolve(p).startsWith(worktreePath) ? resolve(p).slice(worktreePath.length + 1) : p; - return rel; - }); - // cloud R4 P1 (PR-2): some generators write evidence that lives at paths covered by - // .gitignore (cw raw inputs at `generated/capability-wakeup//` — see - // `.gitignore:209`). Stage callback's path list is explicit contract for "must be in - // commit"; `-f` forces inclusion (no-op for non-ignored paths). Without -f, `git add` - // exits non-zero with "paths are ignored" and the whole publish fails. - await exec('git', ['-C', worktreePath, 'add', '-f', '--', ...relativePaths], { timeout: 30_000 }); - await exec('git', ['-C', worktreePath, 'commit', '-m', commitMessage], { timeout: 30_000 }); - - // 5. Push branch to origin - await exec('git', ['-C', worktreePath, 'push', '-u', 'origin', opts.branchName], { timeout: 120_000 }); - pushSucceeded = true; - - // 6. Get commit SHA (after commit, before PR) - const shaResult = await exec('git', ['-C', worktreePath, 'rev-parse', 'HEAD'], { timeout: 10_000 }); - const commitSha = shaResult.stdout.trim(); - - // 7. Open auto-PR via gh. - // 砚砚 R4 P1 cloud: `--repo .` is NOT valid gh syntax (fails with - // 'expected the "[HOST/]OWNER/REPO" format'). Rely on cwd inside the - // worktree — gh auto-detects owner/repo from the git remote. - // - // PR-3 (砚砚 R2): pass each label via separate `--label` flag (gh CLI accepts - // repeated --label X; not comma-separated). `computePublishPolicy` decides - // labels per packet/attribution. - // - // PR-3 R1 (砚砚 cloud): `gh pr create --label X` fails if label doesn't exist - // in repo. Ensure labels exist via `gh label create --force` (idempotent — - // creates if missing, updates if exists; either way safe). Errors swallowed: - // if label creation fails (network / permissions), we still try `gh pr create` - // — better to surface label error there than to block the publish entirely. - const standardLabelMeta: Record = { - 'evidence-only': { - color: '0E8A16', - description: 'F192 auto-verdict artifact PR — cat-owned merge per SOP, not operator', - }, - 'no-action-needed': { - color: 'C5DEF5', - description: 'F192 keep_observe + no actionable findings — interim per-run PR (rollup deferred)', - }, - }; - for (const label of labels ?? []) { - const meta = standardLabelMeta[label]; - const args = ['label', 'create', label, '--force']; - if (meta) { - args.push('--color', meta.color, '--description', meta.description); - } - try { - await exec('gh', args, withHiddenGhCliWindow({ cwd: worktreePath, timeout: 15_000 })); - } catch (err) { - // Best-effort: surface error on gh pr create below if it actually breaks PR. - // (Swallowing here = avoid double-fail on label step; PR create will retry.) - void err; - } - } - const labelFlags = (labels ?? []).flatMap((label) => ['--label', label]); - const prResult = await exec( - 'gh', - [ - 'pr', - 'create', - '--base', - 'main', - '--head', - opts.branchName, - '--title', - prTitle, - '--body', - prBody, - ...labelFlags, - ], - withHiddenGhCliWindow({ cwd: worktreePath, timeout: 60_000 }), - ); - prUrl = - prResult.stdout - .trim() - .split('\n') - .find((line) => line.startsWith('https://')) ?? prResult.stdout.trim(); - prOpened = true; - await afterPublish?.(); - - return { commitSha, prUrl }; - } catch (err) { - if (prOpened && prUrl) { - try { - await exec( - 'gh', - [ - 'pr', - 'close', - prUrl, - '--delete-branch', - '--comment', - 'Closing stale auto-verdict PR because post-publish writeback failed.', - ], - withHiddenGhCliWindow({ cwd: worktreePath, timeout: 60_000 }), - ); - prOpened = false; - } catch (cleanupErr) { - const originalMessage = err instanceof Error ? err.message : String(err); - const cleanupMessage = cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr); - throw new Error( - `post_publish_cleanup_failed: exposed PR ${prUrl} could not be closed after publish hook failed. original=${originalMessage}; cleanup=${cleanupMessage}`, - ); - } - } - throw err; - } finally { - // Cleanup: always attempt worktree removal. `git worktree add -b` can - // create the branch before failing the worktree setup; best-effort - // removal here keeps admin metadata from lingering across retries. - try { - await exec('git', ['-C', deps.repoRoot, 'worktree', 'remove', '--force', worktreePath], { - timeout: 30_000, - }); - } catch { - // Worktree may never have registered or may already be gone. - } - - // 砚砚 R4 P2 + Day-6 cron bug: cleanup on failure so retries don't collide. - // If PR was opened, leave both branches (PR is the source). - // If push succeeded but gh failed → remote branch leaks → next retry's - // worktree-add succeeds locally but push -u rejects (non-fast-forward). - // - // Important: `git worktree add -b` can partially create the local branch - // even when the command throws. Delete only if the branch did NOT exist - // before this publish attempt, otherwise we might destroy a live branch. - if (!prOpened) { - if (!branchExistedBefore) { - try { - await exec('git', ['-C', deps.repoRoot, 'branch', '-D', opts.branchName], { timeout: 10_000 }); - } catch { - // Branch may not exist (or partial create never happened) — best-effort cleanup - } - } - // 砚砚 R13/R14/R15 P2: probe with `gh pr list` (not `pr view`) — view - // exits 1 on "no PR" (the COMMON case after gh pr create transient fail), - // which would conflate "confirmed no PR" with "auth/network inconclusive". - // `gh pr list --head --state open --json state --limit 1` returns: - // probe SUCCESS + empty array → confirmed no open PR, safe to delete - // probe SUCCESS + non-empty array → PR is live, KEEP branch - // probe FAILED (network/auth/etc.) → inconclusive, KEEP branch - // (R14 P2: orphan branch noise < orphaning a live PR's source) - if (pushSucceeded) { - let safeToDelete = false; - try { - const probe = await exec( - 'gh', - ['pr', 'list', '--head', opts.branchName, '--state', 'open', '--json', 'state', '--limit', '1'], - withHiddenGhCliWindow({ cwd: deps.repoRoot, timeout: 30_000 }), - ); - const parsed = JSON.parse(probe.stdout) as Array<{ state?: string }>; - if (Array.isArray(parsed) && parsed.length === 0) safeToDelete = true; - } catch { - // probe inconclusive → keep branch (conservative; orphan branch < deleted live PR source) - } - if (safeToDelete) { - try { - await exec('git', ['-C', deps.repoRoot, 'push', '--delete', 'origin', opts.branchName], { - timeout: 30_000, - }); - } catch { - // Remote branch may not exist or network failed — best effort - } - } - } - } - // Belt-and-suspenders: rmSync in case `git worktree remove` failed - try { - rmSync(worktreePath, { recursive: true, force: true }); - } catch { - // Already gone or never created - } - } - }, - }; -} diff --git a/packages/api/src/infrastructure/harness-eval/publish-verdict/harness-ledger-generator-adapter.ts b/packages/api/src/infrastructure/harness-eval/publish-verdict/harness-ledger-generator-adapter.ts new file mode 100644 index 0000000000..0c9746e31f --- /dev/null +++ b/packages/api/src/infrastructure/harness-eval/publish-verdict/harness-ledger-generator-adapter.ts @@ -0,0 +1,221 @@ +/** + * F257 Eval Engine Wiring — harness-ledger generator adapter. + * + * KD-17 snapshot-first pattern: reads a pre-produced run snapshot + * (written by trigger via harness-ledger-snapshot-provider) instead + * of querying GuardRejectionEventLog directly. Single-read by + * evalRunId — decision and artifact share one data source. + * + * Flow: + * 1. Discriminator: sourceRefs.kind === 'prompt-segments' + * 2. Validate window (start < end, both finite) + * 3. Read stored run snapshot by evalRunId (fail-closed on missing) + * 4. Write verdict markdown + bundle artifacts from snapshot data + * 5. Return paths + * + * Fail-closed: missing snapshot file → 500 (not false verdict). + * The snapshot was produced by queryWindowStrict in the provider — + * Redis errors already surfaced at trigger time. + */ + +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { HarnessLedgerRunSnapshot } from '../harness-ledger-snapshot-provider.js'; +import { buildAttribution, buildByGuardEpisodes, buildVerdictMarkdown } from './harness-ledger-verdict-builders.js'; +import type { PromptSegmentsSourceSelector, VerdictGenerator } from './types.js'; + +/** + * Creates the eval:harness-ledger verdict generator. + * + * No longer takes GuardRejectionEventLog — data access moved to the + * snapshot provider (trigger-time, not publish-time). Generator reads + * the stored snapshot from liveHarnessFeedbackRoot/run-snapshots/. + */ +export function createHarnessLedgerGeneratorAdapter(): VerdictGenerator { + return async (packet, sourceRefs, deps) => { + // Step 1: discriminator check + const kind = (sourceRefs as { kind?: string }).kind; + if (kind !== 'prompt-segments') { + throw new Error( + `harness_ledger_adapter_wrong_kind: received sourceRefs with kind='${kind ?? '(omitted)'}'; expected 'prompt-segments'`, + ); + } + + const selector = sourceRefs as unknown as PromptSegmentsSourceSelector; + + // Step 2: validate window + if (!Number.isFinite(selector.windowStartMs) || !Number.isFinite(selector.windowEndMs)) { + throw new Error('invalid_window: windowStartMs and windowEndMs must be finite numbers'); + } + if (selector.windowEndMs <= selector.windowStartMs) { + throw new Error('invalid_window: windowEndMs must be greater than windowStartMs'); + } + + // Step 3: read stored run snapshot (KD-17 single-read). + // evalRunId is REQUIRED — trigger must produce snapshot before eval cat publishes. + const evalRunId = selector.evalRunId; + if (!evalRunId) { + throw new Error( + 'harness_ledger_adapter_missing_run_id: sourceRefs.evalRunId is required. ' + + 'Trigger must call produceHarnessLedgerRunSnapshot before eval cat invocation (KD-17).', + ); + } + // Path-safety: evalRunId format is validated at MCP + validation layers, + // but defense-in-depth here prevents path traversal even if upstream skips. + if (!/^hlr-\d+-[a-f0-9]{8}$/.test(evalRunId)) { + throw new Error( + `harness_ledger_adapter_invalid_run_id: evalRunId '${evalRunId}' does not match safe format hlr--.`, + ); + } + const snapshotFilePath = join(deps.liveHarnessFeedbackRoot, 'run-snapshots', `${evalRunId}.json`); + if (!existsSync(snapshotFilePath)) { + throw new Error( + `harness_ledger_adapter_snapshot_missing: ${snapshotFilePath} not found. ` + + 'Run snapshot may have been cleaned up or trigger failed to produce it (fail-closed KD-17).', + ); + } + const storedSnapshot = JSON.parse(readFileSync(snapshotFilePath, 'utf8')) as HarnessLedgerRunSnapshot; + + // sol R10 P1-1: owner-scope validation — generator must NOT produce artifacts + // from a snapshot belonging to a different owner. Fail-closed on all three + // states: deps missing owner, snapshot missing owner, mismatch. + if (!deps.ownerUserId) { + throw new Error( + 'harness_ledger_adapter_owner_missing: deps.ownerUserId is required. ' + + 'Generator must run with server-injected owner scope (fail-closed).', + ); + } + if (!storedSnapshot.ownerUserId) { + throw new Error( + 'harness_ledger_adapter_snapshot_owner_missing: stored snapshot lacks ownerUserId. ' + + 'Snapshot may predate owner-scope enforcement — re-trigger to produce a scoped snapshot.', + ); + } + if (deps.ownerUserId !== storedSnapshot.ownerUserId) { + throw new Error( + 'harness_ledger_adapter_owner_mismatch: deps.ownerUserId does not match stored snapshot owner. ' + + 'Cross-owner artifact production is forbidden (fail-closed).', + ); + } + + // KD-17 single-source: verify selector window matches stored snapshot window. + // Prevents drift where cat claims different window than what snapshot actually covers. + if ( + selector.windowStartMs !== storedSnapshot.window.startMs || + selector.windowEndMs !== storedSnapshot.window.endMs + ) { + throw new Error( + `harness_ledger_adapter_window_mismatch: selector window [${selector.windowStartMs}, ${selector.windowEndMs}) ` + + `does not match stored snapshot window [${storedSnapshot.window.startMs}, ${storedSnapshot.window.endMs}). ` + + 'KD-17 invariant: decision and artifact must share the same data source.', + ); + } + + // Extract aggregates from stored snapshot (no re-query). + const { totalEvents, byKind, byGuard } = storedSnapshot; + const hasEvents = totalEvents > 0; + + const generatedAt = new Date().toISOString(); + const evalSnapshotId = `harness-ledger-snapshot-${packet.id}`; + const windowMs = selector.windowEndMs - selector.windowStartMs; + const windowHours = Math.round(windowMs / (3600 * 1000)); + const windowDays = Math.round(windowMs / (24 * 3600 * 1000)); + + // Step 4: write bundle artifacts + const verdictPath = join(deps.harnessFeedbackRoot, 'verdicts', `${packet.id}.md`); + const bundleDir = join(deps.harnessFeedbackRoot, 'bundles', packet.id); + mkdirSync(join(deps.harnessFeedbackRoot, 'verdicts'), { recursive: true }); + mkdirSync(bundleDir, { recursive: true }); + + // Flatten byGuard for snapshot.json frictionCounts (count-only map). + const guardCountMap: Record = {}; + for (const [gid, agg] of Object.entries(byGuard)) { + guardCountMap[gid] = agg.count; + } + + const byGuardEpisodes = buildByGuardEpisodes(byGuard); + + // --- Bundle: snapshot.json --- + const bundleSnapshot = { + verdictId: packet.id, + evalSnapshotId, + featureId: 'F257', + generatedAt, + window: { startMs: selector.windowStartMs, endMs: selector.windowEndMs, durationHours: windowHours }, + totalEvents, + byKind, + byGuard: guardCountMap, + byGuardEpisodes, + sampleAnchors: storedSnapshot.sampleAnchors ?? [], + // sol R2 P2: truncation must survive into the COMMITTED bundle — a + // capped window means every count is a lower bound, and the verdict's + // evidence chain has to say so. Confidence degrades accordingly. + truncated: storedSnapshot.truncated ?? false, + // Sol R1 P2-1: reason breakdown from stored snapshot — self-documents + // which skip reasons contributed (e.g. "all 3 were dedup_active"). + ...(storedSnapshot.byReason ? { byReason: storedSnapshot.byReason } : {}), + components: [ + { + componentId: 'guard-rejection-log', + componentName: 'Guard Rejection Event Log', + activationCounts: { total_events: totalEvents, ...byKind }, + frictionCounts: guardCountMap, + confidence: (storedSnapshot.truncated ?? false) ? 'low' : hasEvents ? 'medium' : 'no-data', + }, + ], + }; + const snapshotJson = JSON.stringify(bundleSnapshot, null, 2); + writeFileSync(join(bundleDir, 'snapshot.json'), snapshotJson); + + // --- Bundle: attribution.json --- + const attribution = buildAttribution({ + verdictId: packet.id, + featureId: 'F257', + evalSnapshotId, + generatedAt, + hasEvents, + byGuard, + windowDays, + windowStartMs: selector.windowStartMs, + windowEndMs: selector.windowEndMs, + }); + writeFileSync(join(bundleDir, 'attribution.json'), JSON.stringify(attribution, null, 2)); + + // --- Bundle: provenance.json --- + const snapshotSha = createHash('sha256').update(snapshotJson).digest('hex'); + const provenance = { + verdictId: packet.id, + rawInputs: [{ path: `bundles/${packet.id}/snapshot.json`, sha256: snapshotSha }], + generatedAt, + generator: { name: 'harness-ledger-generator-adapter', version: '2.0.0' }, + sanitizeRulesVersion: '1.0.0', + // KD-17 provenance: link back to the run snapshot that fed this bundle. + // Sol R1 P2-1: sourceThreadId from stored snapshot (server-injected, not self-reported). + producedBy: { + runId: evalRunId, + ...(storedSnapshot.sourceThreadId ? { sourceThreadId: storedSnapshot.sourceThreadId } : {}), + // Sol R4 P1-1 / Fable ruling: escalation kind provenance. + // Eval cat sees whether this was a confirmed harmful escalation + // or an uncertainty probe (truncation-only, capped scan). + ...(storedSnapshot.escalationKind ? { escalationKind: storedSnapshot.escalationKind } : {}), + }, + }; + writeFileSync(join(bundleDir, 'provenance.json'), JSON.stringify(provenance, null, 2)); + + // --- Verdict markdown --- + const verdictMd = buildVerdictMarkdown({ + packet, + bundleSnapshot, + evalSnapshotId, + hasEvents, + byKind, + guardCountMap, + windowDays, + totalEvents, + }); + writeFileSync(verdictPath, verdictMd); + + return { verdictPath, bundleDir }; + }; +} diff --git a/packages/api/src/infrastructure/harness-eval/publish-verdict/harness-ledger-verdict-builders.ts b/packages/api/src/infrastructure/harness-eval/publish-verdict/harness-ledger-verdict-builders.ts new file mode 100644 index 0000000000..50e17457a8 --- /dev/null +++ b/packages/api/src/infrastructure/harness-eval/publish-verdict/harness-ledger-verdict-builders.ts @@ -0,0 +1,183 @@ +/** + * F257 Eval Engine — harness-ledger verdict + attribution builders. + * + * Extracted from harness-ledger-generator-adapter.ts (350-line hard limit). + * Pure presentation builders — no I/O, no side effects. + */ + +import type { HarnessLedgerRunSnapshot } from '../harness-ledger-snapshot-provider.js'; + +// ── Episode accounting for the committed bundle (PR #41 provenance fix) ── + +/** + * The committed bundle must carry rawEventCount / episodeCount / episode + * metadata so burst claims (e.g. "4 events in 7.044s") are independently + * recheckable from the bundle alone. episodeCount falls back to raw count + * only for legacy snapshots produced before episode coalescing + * (conservative upper bound). + */ +export function buildByGuardEpisodes( + byGuard: HarnessLedgerRunSnapshot['byGuard'], +): Record { + const out: Record = {}; + for (const [gid, agg] of Object.entries(byGuard)) { + out[gid] = { + rawEventCount: agg.count, + episodeCount: agg.episodeCount ?? agg.count, + episodes: agg.episodes ?? [], + }; + } + return out; +} + +// ── Attribution builder ── + +export interface BuildAttributionInput { + verdictId: string; + featureId: string; + evalSnapshotId: string; + generatedAt: string; + hasEvents: boolean; + byGuard: Record; + windowDays: number; + windowStartMs: number; + windowEndMs: number; +} + +export function buildAttribution(input: BuildAttributionInput) { + return { + verdictId: input.verdictId, + featureId: input.featureId, + evalSnapshotId: input.evalSnapshotId, + generatedAt: input.generatedAt, + findings: input.hasEvents + ? Object.entries(input.byGuard).map(([guardId, agg]) => { + const severity: 'low' | 'medium' | 'high' = agg.count >= 20 ? 'high' : agg.count >= 5 ? 'medium' : 'low'; + return { + id: `f257-guard-${guardId}`, + rawEventCount: agg.count, + episodeCount: agg.episodeCount ?? agg.count, + frictionSignal: { type: agg.kinds.join('+'), severity, confidence: 0.7 }, + attribution: { + primaryLayer: 'guard-rejection-log', + evidence: agg.kinds.map((kind) => ({ + type: 'activation-count', + anchor: `guard-rejection-log/${kind}`, + excerpt: `${kind} rejection(s) by guard ${guardId}`, + })), + }, + proposedAction: [ + { action: 'review', target: guardId, rationale: `${agg.count} guard rejection(s) — review pattern` }, + ], + }; + }) + : [], + ...(input.hasEvents + ? {} + : { + noFindingRecord: { + reason: 'No guard rejection events recorded in this window', + evidence: `Zero events in ${input.windowDays}-day window [${input.windowStartMs}, ${input.windowEndMs}).`, + }, + }), + }; +} + +// ── Verdict markdown builder ── + +export interface BuildVerdictMdInput { + packet: { id: string } & Record; + bundleSnapshot: Record; + evalSnapshotId: string; + hasEvents: boolean; + byKind: Record; + guardCountMap: Record; + windowDays: number; + totalEvents: number; +} + +export function buildVerdictMarkdown(input: BuildVerdictMdInput): string { + const { packet, evalSnapshotId, hasEvents, byKind, guardCountMap, windowDays, totalEvents } = input; + const typedPacket = packet as Record; + + const verdictValue = (typedPacket.verdict as string) ?? 'keep_observe'; + const phenomenonDefault = !hasEvents + ? 'Zero guard rejection events in window — baseline accumulation phase' + : `${totalEvents} guard rejection events across ${Object.keys(guardCountMap).length} guard(s)`; + const phenomenon = (typedPacket.phenomenon as string) ?? phenomenonDefault; + + const hue = typedPacket.harnessUnderEval as { featureId?: string; componentId?: string; name?: string } | undefined; + const harnessLine = hue + ? `${hue.featureId}/${hue.componentId} (${hue.name})` + : 'F257/guard-rejection-log (Harness Ledger)'; + + const ownerAskObj = typedPacket.ownerAsk as { requestedAction?: string } | undefined; + const ownerAskLine = + ownerAskObj?.requestedAction ?? + (!hasEvents + ? 'No action required; keep observing until guard rejection events accumulate.' + : `Review ${totalEvents} rejection events for attribution patterns.`); + + const reevalPlan = typedPacket.acceptanceReevalPlan as { nextEvalAt?: string } | undefined; + const reevalLine = reevalPlan?.nextEvalAt + ? `next eval at ${reevalPlan.nextEvalAt}` + : 'next eval scheduled per eval:harness-ledger weekly cadence'; + + const snapshotRef = `snapshot:bundle/${packet.id}/snapshot`; + // Per-finding attribution refs (V2 producer fix; PR #43 fixed historical + // assets). The resolver's allowed-ref set is exactly one ref per + // findings[].id, or `:no-finding` when findings=[] with a + // noFindingRecord — a bare evalSnapshotId ref resolves to nothing. + // finding ids are `f257-guard-` (see buildAttribution). + const attributionRefs = hasEvents + ? Object.keys(guardCountMap).map((guardId) => `attribution:bundle/${packet.id}/f257-guard-${guardId}`) + : [`attribution:bundle/${packet.id}/${evalSnapshotId}:no-finding`]; + + const kindRows = Object.entries(byKind) + .map(([k, c]) => `| ${k} | ${c} |`) + .join('\n'); + const guardRows = Object.entries(guardCountMap) + .map(([g, c]) => `| ${g} | ${c} |`) + .join('\n'); + + return [ + '---', + 'feature_ids: [F257]', + 'topics: [harness-eval, eval-harness-ledger, live-verdict]', + 'doc_kind: harness-feedback', + 'feedback_type: live-verdict', + 'domain_id: eval:harness-ledger', + `packet_id: ${packet.id}`, + `source_snapshot: "${snapshotRef}"`, + '---', + '', + `# eval:harness-ledger Verdict — ${packet.id}`, + '', + `- Verdict: \`${verdictValue}\``, + `- Phenomenon: ${phenomenon}`, + `- Harness: ${harnessLine}`, + `- Owner ask: ${ownerAskLine}`, + `- Re-eval: ${reevalLine}`, + '', + 'Evidence:', + `- ${snapshotRef}`, + ...attributionRefs.map((ref) => `- ${ref}`), + '', + `**Window**: ${windowDays} days | **Events**: ${totalEvents}`, + '', + '## Event Breakdown by Kind', + '', + hasEvents ? `| Kind | Count |\n|------|-------|\n${kindRows}` : '_No events recorded in this window._', + '', + '## Event Breakdown by Guard', + '', + hasEvents ? `| Guard | Count |\n|-------|-------|\n${guardRows}` : '_No events recorded in this window._', + '', + '## Notes', + '', + !hasEvents + ? 'No guard rejection events in this window. The observation layer is active but no guards have triggered rejections yet. This is expected during initial accumulation.' + : `Observed ${totalEvents} guard rejection events over ${windowDays} days across ${Object.keys(byKind).length} event kind(s) and ${Object.keys(guardCountMap).length} guard(s).`, + '', + ].join('\n'); +} diff --git a/packages/api/src/infrastructure/harness-eval/publish-verdict/local-artifact-publisher.ts b/packages/api/src/infrastructure/harness-eval/publish-verdict/local-artifact-publisher.ts new file mode 100644 index 0000000000..47dbb5d9fe --- /dev/null +++ b/packages/api/src/infrastructure/harness-eval/publish-verdict/local-artifact-publisher.ts @@ -0,0 +1,124 @@ +import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { mapPublishVerdictError } from './error-mapping.js'; +import type { ArtifactPublisher, ArtifactRef, PublishArtifactOpts } from './types.js'; + +function isNodeError(err: unknown, code: string): err is NodeJS.ErrnoException { + return err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === code; +} + +export interface LocalArtifactPublisherDeps { + /** Root directory where verdict artifacts are persisted. */ + artifactRoot: string; +} + +function toDomainSlug(domainId: string): string { + return domainId.replace(/:/g, '-'); +} + +function toArtifactUrl(domainSlug: string, artifactId: string): string { + return `artifact://${domainSlug}/${artifactId}`; +} + +/** + * F257 / F192 sunset: durable artifact publisher that stores verdict bundles on + * the local filesystem (under `CAT_CAFE_DATA_DIR` or a configured root), NOT in + * the product Git repository. + * + * Contract: + * - Artifacts live at `///`. + * - The directory preserves the generator layout under + * `docs/harness-feedback/{verdicts,bundles}/` plus replay inputs. + * - Writes are staged to a temp directory and atomically renamed to the final + * path so concurrent publishers and readers never see a partial artifact. + * - Duplicate artifact IDs are rejected (idempotent — publishing the same id + * twice is a client error, not an overwrite). + * - `afterPublish` runs exactly once after the artifact is durably published. + * - On failure, the temp directory is removed. + * + * The filesystem backend can later be replaced by an object store or database + * without changing the ArtifactPublisher contract. + */ +export function createLocalArtifactPublisher(deps: LocalArtifactPublisherDeps): ArtifactPublisher { + return { + async publishArtifact(opts: PublishArtifactOpts): Promise { + const domainSlug = toDomainSlug(opts.packet.domainId); + const artifactId = opts.packet.id; + const finalDir = resolve(deps.artifactRoot, domainSlug, artifactId); + const verdictPath = resolve(finalDir, 'verdict.md'); + const bundleDir = resolve(finalDir, 'bundle'); + + if (existsSync(finalDir)) { + throw new Error( + `artifact_already_exists: artifact '${artifactId}' already exists for domain '${opts.packet.domainId}' at ${finalDir}`, + ); + } + + mkdirSync(deps.artifactRoot, { recursive: true }); + const tempDir = mkdtempSync(resolve(deps.artifactRoot, `.staging-${domainSlug}-${artifactId}-`)); + const harnessFeedbackRoot = resolve(tempDir, 'docs', 'harness-feedback'); + mkdirSync(harnessFeedbackRoot, { recursive: true }); + + let afterPublish: (() => void | Promise) | undefined; + try { + const generated = await opts.generate(harnessFeedbackRoot); + + // Validate that the generator wrote the expected files so the atomic + // rename does not publish an empty or misplaced artifact. + if (!existsSync(generated.verdictPath)) { + throw new Error(`generator did not write verdict.md at expected path: ${generated.verdictPath}`); + } + if (!existsSync(generated.bundleDir)) { + throw new Error(`generator did not write bundle directory at expected path: ${generated.bundleDir}`); + } + + afterPublish = generated.afterPublish; + + // Atomic publication: readers either see the old state (none) or the fully + // written finalDir, never a partial write. The parent domain directory + // is created first so rename(2) does not fail with ENOENT on the dest. + mkdirSync(dirname(finalDir), { recursive: true }); + renameSync(tempDir, finalDir); + } catch (err) { + rmSync(tempDir, { recursive: true, force: true }); + // Concurrent duplicate publish: both callers passed the initial + // existsSync check. Normalize the OS-level rename race to the same + // contract as the upfront duplicate-ID guard. + if (isNodeError(err, 'EEXIST') || isNodeError(err, 'ENOTEMPTY')) { + throw new Error( + `artifact_already_exists: artifact '${artifactId}' already exists for domain '${opts.packet.domainId}' at ${finalDir}`, + ); + } + throw err; + } + + if (afterPublish) { + try { + await afterPublish(); + } catch (afterErr) { + // afterPublish is part of the publication unit of work. If the + // side-effect (e.g. task-outcome SQLite writeback) fails, roll back + // the exposed artifact so the Hub never surfaces a verdict whose + // downstream state is inconsistent. + rmSync(finalDir, { recursive: true, force: true }); + const message = afterErr instanceof Error ? afterErr.message : String(afterErr); + // Preserve typed domain errors (e.g. invalid_episode_verdict_writeback) + // so the handler's error-mapping layer continues to return the correct + // 4xx status instead of a generic 500 publisher_failed. + if (mapPublishVerdictError(message)) { + throw afterErr; + } + throw new Error(`artifact_publish_rollback: afterPublish failed for ${artifactId}: ${message}`); + } + } + + return { + artifactId, + domainSlug, + verdictPath: resolve(finalDir, 'docs', 'harness-feedback', 'verdicts', `${artifactId}.md`), + bundleDir: resolve(finalDir, 'docs', 'harness-feedback', 'bundles', artifactId), + artifactUrl: toArtifactUrl(domainSlug, artifactId), + }; + }, + }; +} diff --git a/packages/api/src/infrastructure/harness-eval/publish-verdict/memory-generator-adapter.ts b/packages/api/src/infrastructure/harness-eval/publish-verdict/memory-generator-adapter.ts index 96f91c8f29..99a9e9365a 100644 --- a/packages/api/src/infrastructure/harness-eval/publish-verdict/memory-generator-adapter.ts +++ b/packages/api/src/infrastructure/harness-eval/publish-verdict/memory-generator-adapter.ts @@ -14,8 +14,7 @@ import { validateMemoryRecallSelector } from './validation.js'; * 2. validateMemoryRecallSelector (structural validator — windowDays in * [1, 90] integer, optional catId/toolName non-empty, no newlines) * 3. provider.resolve(selector) → {recallMetrics, libraryHealth} - * 4. Load EvalDomainRegistryEntry from registry inside isolated harness root - * (registry is on origin/main, included in isolated worktree) + * 4. Load EvalDomainRegistryEntry from the temporary artifact harness root * 5. generateMemoryLiveVerdict with submittedPacket (cat owns verdict; * generator only overrides bundle refs in evidencePacket) * @@ -72,8 +71,8 @@ export function createMemoryGeneratorAdapter(provider: MemoryMetricsProvider): V // memory generator writes `recall-metrics.json` + `library-health.json` at // `/generated/memory//` (referenced by provenance.json - // with sha256). Publisher MUST stage this dir or auto-PR omits raw inputs - // and reviewers/main can't audit/replay the verdict. + // with sha256). The publisher persists the entire artifact staging root, + // including this replay input directory. return { verdictPath: artifact.path, bundleDir: artifact.bundleDir, diff --git a/packages/api/src/infrastructure/harness-eval/publish-verdict/publish-verdict.ts b/packages/api/src/infrastructure/harness-eval/publish-verdict/publish-verdict.ts index b202af1bf7..8473eb387e 100644 --- a/packages/api/src/infrastructure/harness-eval/publish-verdict/publish-verdict.ts +++ b/packages/api/src/infrastructure/harness-eval/publish-verdict/publish-verdict.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import type { CapabilityWakeupSourceSelector } from '../capability-wakeup/capability-wakeup-trial-provider.js'; import { validateCapabilityWakeupSelector } from '../capability-wakeup/capability-wakeup-trial-provider.js'; @@ -12,7 +12,7 @@ import { import { mapPublishVerdictError } from './error-mapping.js'; import { computePublishPolicy } from './publish-policy.js'; import type { - GitPublisher, + ArtifactPublisher, HandlerError, PublishVerdictDeps, PublishVerdictInput, @@ -27,12 +27,14 @@ import { isFrictionSourceRefs, isKnownSourceRefsKind, isMemorySourceRefs, + isPromptSegmentsSourceRefs, isQcMetricsSourceRefs, isSopSourceRefs, isTaskOutcomeSourceRefs, validateAnchorTelemetrySelector, validateFrictionRollupSelector, validateMemoryRecallSelector, + validatePromptSegmentsSelector, validateQcMetricsSelector, validateSopTraceSelector, validateSourceRefsFormat, @@ -40,14 +42,14 @@ import { } from './validation.js'; export type { - GitPublisher, + ArtifactPublisher, + ArtifactRef, HandlerError, - PublishOnIsolatedWorktreeOpts, + PublishArtifactOpts, PublishVerdictDeps, PublishVerdictInput, PublishVerdictSuccess, ResolvedSourceRefs, - StageResult, VerdictGenerator, VerdictSourceRefs, } from './types.js'; @@ -58,22 +60,23 @@ const MAX_PHENOMENON_LEN = 2048; const SAFE_VERDICT_ID = /^[a-z0-9][a-z0-9-]*$/; /** - * F192 Phase H — Verdict Publishing Pipeline (砚砚 R0 Path B narrowed). + * F192 Phase H / F257 sunset — Verdict Publishing Pipeline. * Eval cat calls cat_cafe_publish_verdict MCP → handler validates → generator - * runs INSIDE isolated worktree (砚砚 R1 P1 #1 + R7 cloud: live tree NEVER touched) - * → GitPublisher commits + pushes + opens auto-PR. Replaces PR #2091. + * writes to a temporary output root → ArtifactPublisher atomically commits the + * artifact to a durable store. Runtime artifacts do NOT live in the product Git + * repository. */ -const defaultGitPublisher: GitPublisher = { - async publishOnIsolatedWorktree() { - throw new Error('GitPublisher not injected (must wire real isolated-worktree impl at route layer)'); +const defaultArtifactPublisher: ArtifactPublisher = { + async publishArtifact() { + throw new Error('ArtifactPublisher not injected (must wire real impl at route layer)'); }, }; /** * AC-H1: Validate VerdictHandoffPacket schema (server NEVER 造 evidence). * AC-H7 partial: input.domain must match packet.domainId. - * AC-H2: call generator → branch + commit + push + auto-PR → return SHA + URL. + * AC-H2: call generator → atomically publish outside product Git → return artifact ID + URL. * * F192 Phase H 收尾 PR-2 (砚砚 R1 P1): handler is now domain-agnostic. * - Replaced hardcoded `packet.domainId !== 'eval:a2a'` check with @@ -180,18 +183,8 @@ export async function handlePublishVerdict( detail: `packet.phenomenon must be <= ${MAX_PHENOMENON_LEN} chars (got ${packet.phenomenon.length})`, }; } - // Idempotency fast-fail: live-tree existsSync catches common dup quickly. - // 砚砚 R3 P1 #2 cloud: NOT authoritative — if API checkout is stale vs origin/main, - // dup-on-main slips through. Authoritative re-check inside isolated worktree below. - const liveVerdictPath = resolve(deps.harnessFeedbackRoot, 'verdicts', `${packet.id}.md`); - const liveBundleDir = resolve(deps.harnessFeedbackRoot, 'bundles', packet.id); - if (existsSync(liveVerdictPath) || existsSync(liveBundleDir)) { - return { - status: 409, - error: 'verdict_already_exists', - detail: `packet.id '${packet.id}' already has a verdict file or bundle directory in the live worktree. Pick a different id — overwriting existing Eval Hub evidence is forbidden (data integrity).`, - }; - } + // Idempotency is enforced by ArtifactPublisher.publishArtifact (atomic check + + // rename). No live-tree fast-fail is needed in the artifact-store era. // PR-2 (砚砚 R1 P1): handler pre-validates sourceRefs shape per kind for proper // 4xx error codes. Adapter-level validation is defense-in-depth (catches when @@ -238,6 +231,10 @@ export async function handlePublishVerdict( // F253 Phase C: qc-metrics-rollup selector. const selectorError = validateQcMetricsSelector(input.sourceRefs); if (selectorError) return { status: 400, error: 'invalid_source_ref', detail: selectorError }; + } else if (isPromptSegmentsSourceRefs(input.sourceRefs)) { + // F257 Phase A Line B: prompt-segments selector (harness-ledger, fail-closed). + const selectorError = validatePromptSegmentsSelector(input.sourceRefs); + if (selectorError) return { status: 400, error: 'invalid_source_ref', detail: selectorError }; } else if (isA2aSourceRefs(input.sourceRefs)) { const refsCheck = validateSourceRefsFormat(input.sourceRefs); if (!refsCheck.ok) return refsCheck.error; @@ -272,93 +269,64 @@ export async function handlePublishVerdict( }; } - // AC-H2: delegate isolated-worktree lifecycle to GitPublisher. - // Generator runs inside the isolated worktree; live harnessFeedbackRoot is never mutated. - // Branch uniqueness/race protection is delegated to git worktree add -b. - // PR-2: stage callback stays domain-agnostic; adapters resolve their own sources. - const gitPublisher = deps.gitPublisher ?? defaultGitPublisher; + // F257 / F192 sunset: delegate durable publication to ArtifactPublisher. + // Generator writes into a temporary output root; the publisher atomically + // commits the artifact to a durable store outside the product Git repository. + const artifactPublisher = deps.artifactPublisher ?? defaultArtifactPublisher; const generator: VerdictGenerator = deps.generator; // checked above (501 if missing) - const domainSlug = packet.domainId.replace(/:/g, '-'); - const branchName = `verdict/auto/${domainSlug}/${packet.id}`; - let artifact: { + let generated: { verdictPath: string; bundleDir: string; extraStagedPaths?: string[]; afterPublish?: () => void | Promise; } | null = null; try { - const { commitSha, prUrl } = await gitPublisher.publishOnIsolatedWorktree({ - branchName, - sourceBase: 'origin/main', - async stage(worktreeRoot) { - const isolatedHarnessFeedback = `${worktreeRoot}/docs/harness-feedback`; - // 砚砚 R3 P1 #2 cloud: AUTHORITATIVE dup check (origin/main truth). - const isoVerdictPath = resolve(isolatedHarnessFeedback, 'verdicts', `${packet.id}.md`); - const isoBundleDir = resolve(isolatedHarnessFeedback, 'bundles', packet.id); - if (existsSync(isoVerdictPath) || existsSync(isoBundleDir)) { - throw new Error( - `verdict_already_exists_on_main: packet.id '${packet.id}' already exists on origin/main. Pick a different id.`, - ); - } - artifact = await generator(packet, input.sourceRefs, { - harnessFeedbackRoot: isolatedHarnessFeedback, + const ref = await artifactPublisher.publishArtifact({ + packet, + sourceRefs: input.sourceRefs, + async generate(outputRoot) { + generated = await generator(packet, input.sourceRefs, { + harnessFeedbackRoot: outputRoot, liveHarnessFeedbackRoot: deps.harnessFeedbackRoot, ownerUserId: input.ownerUserId, taskOutcomeDbPath: deps.taskOutcomeDbPath, eventMemoryDbPath: deps.eventMemoryDbPath, }); - // PR-3 (砚砚 R2): read attribution.json from bundle to compute publish policy. - // Generator writes attribution.json into bundleDir; if absent or parse fails, - // `computePublishPolicy` fail-opens to regular_pr (砚砚 R2 contract). - let attribution: unknown; - try { - const attrPath = resolve(artifact.bundleDir, 'attribution.json'); - if (existsSync(attrPath)) { - attribution = JSON.parse(readFileSync(attrPath, 'utf8')); - } - } catch { - // Fail-open: undefined → computePublishPolicy returns regular_pr - } - const policy = computePublishPolicy(packet, attribution); - const policyFooter = - policy.mode === 'evidence_only_interim_pr' - ? `\n\n---\n**Cat-owned artifact gate — No operator merge needed.**\n(Interim: keep_observe + no actionable findings. Rollup mechanism deferred to future Phase. See docs/SOP.md § artifact-only-pr-merge-gate for cat merge contract.)` - : policy.labels.includes('evidence-only') - ? `\n\n---\n**Cat-owned artifact gate — No operator merge needed.**\n(Actionable findings present; eval domain owner cat merges per docs/SOP.md § artifact-only-pr-merge-gate.)` - : ''; - return { - // PR-2 R3 P1 (cloud): stage extra paths the generator wrote (cw raw inputs) - // so the auto-PR includes all evidence referenced by provenance.json. - paths: [artifact.verdictPath, artifact.bundleDir, ...(artifact.extraStagedPaths ?? [])], - commitMessage: `verdict(${packet.domainId}): ${packet.id} — ${packet.verdict}\n\n${packet.phenomenon}\n\n[published via cat_cafe_publish_verdict MCP]`, - prTitle: `verdict(${packet.domainId}): ${packet.id}`, - prBody: `Verdict published via cat_cafe_publish_verdict MCP tool.\n\nVerdict: ${packet.verdict}\nDomain: ${packet.domainId}\nPhenomenon: ${packet.phenomenon}\n\nReviewed by: ${packet.ownerAsk.targetOwnerCatId}\nAction: ${packet.ownerAsk.requestedAction}${policyFooter}`, - labels: policy.labels, - afterPublish: artifact.afterPublish, - }; + return generated; }, }); - // Stage must have produced artifact (proves generator ran in isolated worktree) - if (!artifact) { - return { status: 500, error: 'internal', detail: 'stage callback did not produce artifact' }; + if (!generated) { + return { status: 500, error: 'internal', detail: 'generate callback did not produce artifact' }; + } + + // PR-3 (砚砚 R2): read attribution.json from the durable bundle to compute publish + // policy. In the old Git-publisher era this drove PR labels/body; in the artifact + // era it is retained for metadata/logging and future policy-driven side effects. + let attribution: unknown; + try { + const attrPath = resolve(ref.bundleDir, 'attribution.json'); + if (existsSync(attrPath)) { + attribution = JSON.parse(readFileSync(attrPath, 'utf8')); + } + } catch { + // Fail-open: undefined → computePublishPolicy returns regular_pr } - // 砚砚 R12 P2 cloud: returned paths are REPO-RELATIVE (resolve under origin/main - // post-merge), NOT the generator's absolute paths inside the temp worktree which - // is removed in finally — those would be dangling references at response time. + computePublishPolicy(packet, attribution); // retained for audit/metadata + return { ok: true, - verdictPath: `docs/harness-feedback/verdicts/${packet.id}.md`, - bundleDir: `docs/harness-feedback/bundles/${packet.id}`, - commitSha, - prUrl, + verdictPath: ref.verdictPath, + bundleDir: ref.bundleDir, + artifactId: ref.artifactId, + artifactUrl: ref.artifactUrl, }; } catch (err) { const message = err instanceof Error ? err.message : String(err); const mapped = mapPublishVerdictError(message); if (mapped) return mapped; - if (!artifact) return { status: 500, error: 'generator_failed', detail: message }; - return { status: 500, error: 'git_or_gh_failed', detail: message }; + if (!generated) return { status: 500, error: 'generator_failed', detail: message }; + return { status: 500, error: 'publisher_failed', detail: message }; } } diff --git a/packages/api/src/infrastructure/harness-eval/publish-verdict/types.ts b/packages/api/src/infrastructure/harness-eval/publish-verdict/types.ts index 8dc2be3f3d..a92fa27e16 100644 --- a/packages/api/src/infrastructure/harness-eval/publish-verdict/types.ts +++ b/packages/api/src/infrastructure/harness-eval/publish-verdict/types.ts @@ -7,43 +7,45 @@ import type { TaskOutcomeVerdict } from '../task-outcome/task-outcome-episode.js import type { VerdictHandoffPacket } from '../verdict-handoff.js'; /** - * F192 Phase H — Verdict Publishing Pipeline types. - * Extracted from publish-verdict.ts per AGENTS.md 350-line hard limit. + * F257 / F192 sunset: neutral artifact publisher contract. The publisher owns + * durable publication of verdict artifacts (verdict.md + bundle/) to a store that is + * NOT the product Git repository. Eval Hub read-model reads from the same store. */ +export interface ArtifactRef { + /** Stable identifier for this verdict artifact. */ + artifactId: string; + /** Domain slug derived from packet.domainId (e.g. 'eval-a2a'). */ + domainSlug: string; + /** Absolute path to the committed verdict.md. */ + verdictPath: string; + /** Absolute path to the committed bundle directory. */ + bundleDir: string; + /** Stable reference that can be stored or returned to callers. */ + artifactUrl: string; +} -export interface StageResult { - /** Absolute paths under the isolated worktree to `git add`. */ - paths: string[]; - commitMessage: string; - prTitle: string; - prBody: string; - /** - * F192 Phase H 收尾 PR-3 (砚砚 R2): per-PR labels driven by `computePublishPolicy`. - * GitPublisher passes each as `--label X` to `gh pr create`. Omit/empty → no labels. - * Standard labels: - * - `evidence-only`: artifact-only PR; merge gate is artifact-only-pr-merge-gate (SOP), - * not full pnpm gate. NOT a regular code review request. - * - `no-action-needed`: keep_observe verdict with noFindingRecord — interim per-run PR; - * rollup mechanism deferred to future Phase. - */ - labels?: string[]; +export interface PublishArtifactOpts { + packet: VerdictHandoffPacket; + sourceRefs: VerdictSourceRefs; /** - * Optional live side effect that runs after commit/push/PR creation succeeds - * but before the publisher returns success. If it fails, the publisher must - * clean up the newly exposed PR/branch before surfacing the error. + * Generator callback. The publisher creates a temporary output directory and + * passes it to the generator; the generator must write verdict.md + bundle/ + * under that directory. The publisher atomically publishes the directory to the + * artifact store and then runs afterPublish (if provided) exactly once. + * + * The caller closes over any GeneratorDeps the generator needs; the publisher + * is storage-agnostic. */ - afterPublish?: () => void | Promise; -} - -export interface PublishOnIsolatedWorktreeOpts { - branchName: string; - sourceBase: string; // e.g. 'origin/main' - /** Generator + artifact production happens inside the isolated worktree. */ - stage: (worktreeRoot: string) => Promise; + generate: (outputRoot: string) => Promise<{ + verdictPath: string; + bundleDir: string; + extraStagedPaths?: string[]; + afterPublish?: () => void | Promise; + }>; } -export interface GitPublisher { - publishOnIsolatedWorktree(opts: PublishOnIsolatedWorktreeOpts): Promise<{ commitSha: string; prUrl: string }>; +export interface ArtifactPublisher { + publishArtifact(opts: PublishArtifactOpts): Promise; } /** @@ -127,6 +129,28 @@ export interface AnchorTelemetrySourceSelector { windowEndMs: number; } +/** + * F257 Phase A Line B — replayable prompt-segments guard rejection selector for eval:harness-ledger. + * Provider resolves this window selector → GuardRejectionEventLog.queryWindow → events. + * Shape follows the standard window pattern (like anchor-telemetry, qc-metrics, friction). + */ +export interface PromptSegmentsSourceSelector { + kind: 'prompt-segments'; + /** Window start (inclusive), epoch ms */ + windowStartMs: number; + /** Window end (exclusive), epoch ms; must be > windowStartMs */ + windowEndMs: number; + /** + * KD-17 snapshot-first: trigger pre-produces a run snapshot and passes + * this ID so the generator reads the SAME stored data (single-read, + * no re-query). Generator fails closed on missing snapshot. + * Format: `hlr--` — validated at MCP + generator layer. + */ + evalRunId: string; + /** Optional guard ID filter (e.g. 'hold_ball_rate_limit'). */ + guardId?: string; +} + /** * F192 Phase H 收尾 PR-2 — `VerdictSourceRefs` is a discriminated union (砚砚 R1 Q3). * - a2a branch: `{snapshotName, attributionName}` (kind optional, default a2a) @@ -137,6 +161,7 @@ export interface AnchorTelemetrySourceSelector { * - friction branch: `FrictionRollupSourceSelector` (kind required, F245 PR1b live sink) * - anchor-telemetry branch: `AnchorTelemetrySourceSelector` (kind required, F236 Track-2) * - qc branch: `QcMetricsSelector` (kind required, F253 Phase C) + * - prompt-segments branch: `PromptSegmentsSourceSelector` (kind required, F257 Phase A Line B) * * 砚砚 R1 P1 #2: generator MUST receive explicit `sources` (sanitized * evidence refs / replayable selector); tool NEVER fabricates evidence. @@ -149,11 +174,13 @@ export type VerdictSourceRefs = | SopTraceSourceSelector | FrictionRollupSourceSelector | AnchorTelemetrySourceSelector - | QcMetricsSelector; + | QcMetricsSelector + | PromptSegmentsSourceSelector; /** * Resolved evidence source paths (a2a only — for backward-compat helpers in validation.ts). - * 砚砚 R7 cloud: resolved INSIDE isolated worktree so paths live in-repo for provenance. + * Paths are resolved inside the publisher's temporary artifact root so copied + * evidence is included in the durable artifact bundle. * * cw adapter does NOT use this — it resolves selector → trials via provider port. */ @@ -166,8 +193,8 @@ export interface ResolvedSourceRefs { * Generator contract — produces verdict.md + bundle/ for the packet's domain. * * F192 Phase H 收尾 PR-2 (砚砚 R1 Q1): adapter is self-contained — receives RAW - * `sourceRefs` (not pre-resolved) and both roots (live + isolated). Each adapter: - * - a2a: validate basenames, resolve in live root, copy to isolated root, call generateA2aLiveVerdict + * `sourceRefs` (not pre-resolved) and both roots (live + artifact staging). Each adapter: + * - a2a: validate basenames, resolve in live root, copy to artifact staging, call generateA2aLiveVerdict * - capability-wakeup: validate selector, provider.resolve(selector) → trials, call generateCapabilityWakeupLiveVerdict * * Handler stays domain-agnostic (砚砚 R1 P1: route layer dispatches single generator @@ -181,18 +208,18 @@ export type VerdictGenerator = ( verdictPath: string; bundleDir: string; /** - * F192 Phase H 收尾 PR-2 R3 P1 (cloud): extra paths the generator wrote that the - * publisher MUST also `git add` (e.g. cw's `generated/capability-wakeup//` - * raw input dir, referenced by provenance.json). Omit/empty when generator writes - * everything under `bundleDir`. + * Legacy compatibility field for extra paths written under the artifact staging + * root (for example raw inputs referenced by provenance.json). The local artifact + * publisher commits the entire staging root atomically, so callers do not need to + * perform any Git operation. Omit/empty when everything lives under `bundleDir`. */ extraStagedPaths?: string[]; - /** Optional live side effect that may run only after commit/push/PR creation succeeds. */ + /** Optional live side effect that may run only after durable artifact publication succeeds. */ afterPublish?: () => void | Promise; }>; export interface GeneratorDeps { - /** ISOLATED worktree's docs/harness-feedback — where generator writes verdict.md + bundle. */ + /** Temporary artifact root where the generator writes verdict.md + bundle. */ harnessFeedbackRoot: string; /** LIVE checkout's docs/harness-feedback — a2a needs this to read raw snapshot/attribution YAML * that are gitignored from origin/main (砚砚 R17 P1 cloud). cw doesn't use it. */ @@ -207,8 +234,8 @@ export interface GeneratorDeps { export interface PublishVerdictDeps { harnessFeedbackRoot: string; - /** AC-H2 + 砚砚 R1 P1 #1: isolated publish worktree (default throws). */ - gitPublisher?: GitPublisher; + /** F257 / F192 sunset: durable artifact publisher (default throws). */ + artifactPublisher?: ArtifactPublisher; /** AC-H2: domain-specific generator (default throws — route-layer must inject per-domain). */ generator?: VerdictGenerator; /** 砚砚 R6 P1: Redis client for OQ-20 eval-cat overrides (symmetric with trigger-now). */ @@ -234,8 +261,8 @@ export interface PublishVerdictSuccess { ok: true; verdictPath: string; bundleDir: string; - commitSha: string; - prUrl: string; + artifactId: string; + artifactUrl: string; } export interface HandlerError { diff --git a/packages/api/src/infrastructure/harness-eval/publish-verdict/validation.ts b/packages/api/src/infrastructure/harness-eval/publish-verdict/validation.ts index 40ac35045d..c3448561db 100644 --- a/packages/api/src/infrastructure/harness-eval/publish-verdict/validation.ts +++ b/packages/api/src/infrastructure/harness-eval/publish-verdict/validation.ts @@ -9,6 +9,7 @@ import type { AnchorTelemetrySourceSelector, HandlerError, MemoryRecallSourceSelector, + PromptSegmentsSourceSelector, ResolvedSourceRefs, SopTraceSourceSelector, TaskOutcomeSnapshotSourceRefs, @@ -76,6 +77,15 @@ export function isQcMetricsSourceRefs(refs: VerdictSourceRefs | undefined): refs return refs.kind === 'qc-metrics-rollup'; } +/** + * F257 Phase A Line B — discriminator helper for prompt-segments selector (harness-ledger). + */ +export function isPromptSegmentsSourceRefs(refs: VerdictSourceRefs | undefined): refs is PromptSegmentsSourceSelector { + if (!refs) return false; + if (!('kind' in refs)) return false; + return refs.kind === 'prompt-segments'; +} + /** * F253 Phase C — structural validator for QC metrics selector. * Returns user-facing error detail; handler maps to 400 invalid_source_ref. @@ -101,6 +111,7 @@ export const KNOWN_SOURCE_REFS_KINDS = [ 'anchor-telemetry-snapshot', 'capability-wakeup-trial-window', 'memory-recall-snapshot', + 'prompt-segments', 'qc-metrics-rollup', 'sop-trace-eval', 'task-outcome-snapshot', @@ -153,6 +164,7 @@ export function inferSourceRefsKind(refs: VerdictSourceRefs | undefined): string if (isAnchorTelemetrySourceRefs(refs)) return 'anchor-telemetry-snapshot'; if (isFrictionSourceRefs(refs)) return 'friction-rollup-snapshot'; if (isQcMetricsSourceRefs(refs)) return 'qc-metrics-rollup'; + if (isPromptSegmentsSourceRefs(refs)) return 'prompt-segments'; if (isA2aSourceRefs(refs)) return 'a2a-snapshot-attribution'; if (refs && typeof refs === 'object' && 'kind' in refs && typeof refs.kind === 'string') { return refs.kind; @@ -247,6 +259,35 @@ export function validateAnchorTelemetrySelector(selector: AnchorTelemetrySourceS return null; } +/** + * F257 Phase A Line B — structural validator for prompt-segments selector (harness-ledger). + * Window-only (like anchor-telemetry) + optional guardId filter. + */ +export function validatePromptSegmentsSelector(selector: PromptSegmentsSourceSelector): string | null { + if (selector.kind !== 'prompt-segments') { + return `expected kind='prompt-segments', got '${(selector as { kind?: string }).kind ?? '(omitted)'}'`; + } + if (typeof selector.windowStartMs !== 'number' || !Number.isFinite(selector.windowStartMs)) { + return 'windowStartMs must be a finite number'; + } + if (typeof selector.windowEndMs !== 'number' || !Number.isFinite(selector.windowEndMs)) { + return 'windowEndMs must be a finite number'; + } + if (selector.windowEndMs <= selector.windowStartMs) { + return 'windowEndMs must be greater than windowStartMs'; + } + // KD-17: evalRunId is required and must match generator format (path-safe). + if (!selector.evalRunId || typeof selector.evalRunId !== 'string') { + return 'evalRunId is required (KD-17 snapshot-first)'; + } + if (!/^hlr-\d+-[a-f0-9]{8}$/.test(selector.evalRunId)) { + return 'evalRunId must match generator format: hlr-- (path traversal rejected)'; + } + const guardIdError = validateOptionalIdField(selector.guardId, 'guardId'); + if (guardIdError) return guardIdError; + return null; +} + function hasParentTraversalSegment(value: string): boolean { return value.split(/[\\/]+/).some((segment) => segment === '..'); } diff --git a/packages/api/src/infrastructure/harness-eval/segment-judgment-engine.ts b/packages/api/src/infrastructure/harness-eval/segment-judgment-engine.ts new file mode 100644 index 0000000000..6cc4f46225 --- /dev/null +++ b/packages/api/src/infrastructure/harness-eval/segment-judgment-engine.ts @@ -0,0 +1,339 @@ +/** + * F257 Segment Judgment Engine — deterministic per-segment verdict producer. + * + * Consumes two data sources: + * 1. HarnessLedgerRunSnapshot (guard rejection events, from snapshot provider) + * 2. InjectionTraceStore.queryWindow (per-segment injection traces) + * + * Produces SegmentJudgment[] following frozen judgment-schema-v1 (§2). + * + * Verdict rules are DETERMINISTIC (no LLM) — the eval cat receives these + * as precomputed evidence alongside the guard event snapshot. + * + * Correlation: threadId + catId + [timestamp ± W] (v1 = window, W = 120s). + * correlationConfidence always 'window' in v1. + * + * Integration point: called after produceHarnessLedgerRunSnapshot(), + * before eval cat invocation (trigger-now / eval-domain-daily). + */ + +import type { InjectionTraceSummary, SegmentVerdict } from '@cat-cafe/shared'; +import type { InjectionTraceStore } from '../../domains/prompt-hooks/InjectionTraceStore.js'; +import type { HarnessLedgerRunSnapshot } from './harness-ledger-snapshot-provider.js'; + +// --------------------------------------------------------------------------- +// Types (judgment schema v1 §2) +// --------------------------------------------------------------------------- + +// SegmentVerdict vocabulary is canonical in @cat-cafe/shared (single source of truth +// for engine + Console). Re-exported here for existing consumers (e.g. SegmentJudgmentCache). +export type { SegmentVerdict }; + +export interface CountWithProvenance { + value: number; + how_counted: string; +} + +export interface SegmentJudgment { + judgmentId: string; + segmentId: string; + segmentVersion: number | null; + window: { startMs: number; endMs: number }; + verdict: SegmentVerdict; + evidence: { + injectionCount: CountWithProvenance; + violationCount: CountWithProvenance; + denominatorKind: 'fired-count' | 'session-count' | 'none'; + eventRefs: string[]; + correlationConfidence: 'window' | 'exact'; + }; + pressure: { + observabilityDeadline: string | null; + nextRequiredAction: string | null; + }; + producedBy: { domainId: string; runId: string; evalCat: string }; +} + +// --------------------------------------------------------------------------- +// Engine input +// --------------------------------------------------------------------------- + +/** Raw guard rejection event for per-event correlation. */ +export interface RawGuardEvent { + eventId: string; + guardId: string; + threadId: string; + catId: string; + timestamp: number; +} + +export interface JudgmentEngineInput { + snapshot: HarnessLedgerRunSnapshot; + evalCat: string; + /** Thread IDs to scan for injection traces (from thread store or guard events). */ + threadIds: string[]; + /** + * Raw guard events for per-event timestamp correlation. + * When provided, enables per-segment violationCount via ±120s window join. + * When absent, falls back to snapshot.totalEvents as aggregate (less precise). + */ + rawGuardEvents?: RawGuardEvent[]; +} + +export interface JudgmentEngineDeps { + traceStore: InjectionTraceStore; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Correlation window half-width (±120s). judgment schema v1 §1. */ +const CORRELATION_WINDOW_MS = 120_000; +/** Max event refs per judgment (schema says "抽样上限 20"). */ +const MAX_EVENT_REFS = 20; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Produce per-segment judgments from guard events + injection traces. + * + * Steps: + * 1. Query injection traces for all threads in the window + * 2. Collect all unique segments observed across traces + * 3. For each segment: count injections, correlate guard events, apply verdict + * 4. Return SegmentJudgment[] + */ +export async function produceSegmentJudgments( + deps: JudgmentEngineDeps, + input: JudgmentEngineInput, +): Promise { + const { snapshot, evalCat, threadIds } = input; + const { startMs, endMs } = snapshot.window; + + // 1. Collect all injection traces in the window across threads + const allTraces: InjectionTraceSummary[] = []; + for (const threadId of threadIds) { + const traces = await deps.traceStore.queryWindow(threadId, startMs, endMs); + allTraces.push(...traces); + } + + if (allTraces.length === 0) { + return []; // No traces = nothing to judge + } + + // 2. Aggregate per-segment: injectionCount + observed versions + const segmentStats = aggregateSegmentStats(allTraces); + + // 3. Build guard event index for correlation + const guardEventIndex = buildGuardEventIndex(input.rawGuardEvents); + + // 4. For each segment, correlate and produce judgment + const judgments: SegmentJudgment[] = []; + let seq = 1; + const datePrefix = snapshot.producedAt.slice(0, 10).replace(/-/g, ''); + + for (const [, stats] of segmentStats.entries()) { + // Correlate: find guard events whose timestamp is within ±120s + // of any trace where this segment was fired + const correlated = correlateGuardEvents(stats.firedTimestamps, guardEventIndex); + + const judgment = produceVerdict({ + judgmentId: `sj-${datePrefix}-${String(seq).padStart(3, '0')}`, + segmentId: stats.segmentId, + segmentVersion: stats.version, + window: { startMs, endMs }, + injectionCount: stats.firedCount, + violationCount: correlated.count, + eventRefs: correlated.eventRefs, + evalRunId: snapshot.evalRunId, + evalCat, + }); + + judgments.push(judgment); + seq++; + } + + return judgments; +} + +// --------------------------------------------------------------------------- +// Internal: per-segment aggregation +// --------------------------------------------------------------------------- + +interface SegmentStats { + segmentId: string; + firedCount: number; + /** Timestamps of traces where this segment was fired (for correlation). */ + firedTimestamps: Array<{ threadId: string; catId: string; timestamp: number }>; + version: number | null; +} + +/** IDs that represent v0 fallback data, not per-hook segments. */ +const SKIP_SEGMENT_IDS = new Set(['per-turn-aggregate', 'session-init-pack-only']); + +/** Composite key for per-version grouping (R7). */ +function segmentVersionKey(segmentId: string, version: number | null): string { + return version != null ? `${segmentId}::${version}` : segmentId; +} + +export function isFired(seg: { status: string; pipelineStatus?: string }): boolean { + return seg.status === 'observed' && (seg.pipelineStatus === 'fired' || !seg.pipelineStatus); +} + +function aggregateSegmentStats(traces: InjectionTraceSummary[]): Map { + const stats = new Map(); + + for (const trace of traces) { + for (const seg of trace.segments) { + if (SKIP_SEGMENT_IDS.has(seg.segmentId)) continue; + + // R7: group by (segmentId, version) composite key so traces from + // different versions produce independent judgments instead of one + // mixed-metric judgment. Traces without version fall back to bare segmentId. + const key = segmentVersionKey(seg.segmentId, seg.version ?? null); + let entry = stats.get(key); + if (!entry) { + entry = { segmentId: seg.segmentId, firedCount: 0, firedTimestamps: [], version: seg.version ?? null }; + stats.set(key, entry); + } + + if (isFired(seg)) { + entry.firedCount++; + entry.firedTimestamps.push({ + threadId: trace.threadId, + catId: trace.catId, + timestamp: trace.timestamp, + }); + } + } + } + + return stats; +} + +// --------------------------------------------------------------------------- +// Internal: guard event correlation +// --------------------------------------------------------------------------- + +interface GuardEventEntry { + eventId: string; + guardId: string; + threadId: string; + catId: string; + timestamp: number; +} + +function buildGuardEventIndex(rawEvents?: RawGuardEvent[]): GuardEventEntry[] { + // Raw events carry full threadId/catId for v1 three-key correlation. + // sampleAnchors lack threadId/catId and CANNOT satisfy the frozen v1 join + // contract (threadId + catId + ±120s). Returning them with empty strings + // would produce false attribution across threads/cats. When raw events are + // absent, return empty → violationCount = 0 with explicit evidence gap. + if (!rawEvents || rawEvents.length === 0) return []; + return rawEvents.map((e) => ({ + eventId: e.eventId, + guardId: e.guardId, + threadId: e.threadId, + catId: e.catId, + timestamp: e.timestamp, + })); +} + +interface CorrelationResult { + count: number; + eventRefs: string[]; +} + +function correlateGuardEvents( + firedTimestamps: Array<{ threadId: string; catId: string; timestamp: number }>, + guardEvents: GuardEventEntry[], +): CorrelationResult { + if (firedTimestamps.length === 0 || guardEvents.length === 0) { + return { count: 0, eventRefs: [] }; + } + + // v1 three-key correlation (frozen schema §1): + // join = threadId + catId + [timestamp ± CORRELATION_WINDOW_MS] + // All three must match. Guard events without threadId/catId never enter + // the index (buildGuardEventIndex drops sampleAnchors). + const matchedEventIds = new Set(); + + for (const guard of guardEvents) { + for (const fired of firedTimestamps) { + const sameThread = guard.threadId === fired.threadId; + const sameCat = guard.catId === fired.catId; + const withinWindow = Math.abs(guard.timestamp - fired.timestamp) <= CORRELATION_WINDOW_MS; + if (sameThread && sameCat && withinWindow) { + matchedEventIds.add(guard.eventId); + break; + } + } + } + + return { + count: matchedEventIds.size, + eventRefs: [...matchedEventIds].slice(0, MAX_EVENT_REFS), + }; +} + +// --------------------------------------------------------------------------- +// Internal: verdict rules (judgment schema v1, deterministic) +// --------------------------------------------------------------------------- + +interface VerdictInput { + judgmentId: string; + segmentId: string; + segmentVersion: number | null; + window: { startMs: number; endMs: number }; + injectionCount: number; + violationCount: number; + eventRefs: string[]; + evalRunId: string; + evalCat: string; +} + +function produceVerdict(input: VerdictInput): SegmentJudgment { + const hasDenominator = input.injectionCount > 0; + const denominatorKind: 'fired-count' | 'none' = hasDenominator ? 'fired-count' : 'none'; + + // Verdict rules (v1, single-window deterministic): + // - injectionCount > 0 → 'alive' (segment fires, violation rate computable) + // violationRate = 0% is a valid alive measurement, not unmeasurable. + // - injectionCount == 0 → 'unmeasurable' (no denominator, cannot compute rate) + // - 'dormant' requires consecutive 2 zero-fire periods — single window can't determine. + // Eval cat upgrades alive→dormant when it has cross-window history. + const verdict: SegmentVerdict = hasDenominator ? 'alive' : 'unmeasurable'; + + return { + judgmentId: input.judgmentId, + segmentId: input.segmentId, + segmentVersion: input.segmentVersion, + window: input.window, + verdict, + evidence: { + injectionCount: { + value: input.injectionCount, + how_counted: 'injection-trace-store-queryWindow-fired-count', + }, + violationCount: { + value: input.violationCount, + how_counted: 'guard-rejection-snapshot-window-correlation-120s', + }, + denominatorKind, + eventRefs: input.eventRefs, + correlationConfidence: 'window', + }, + pressure: { + observabilityDeadline: null, + nextRequiredAction: null, + }, + producedBy: { + domainId: 'eval:harness-ledger', + runId: input.evalRunId, + evalCat: input.evalCat, + }, + }; +} diff --git a/packages/api/src/infrastructure/harness-eval/skip-reason-eligibility.ts b/packages/api/src/infrastructure/harness-eval/skip-reason-eligibility.ts new file mode 100644 index 0000000000..a7e9953599 --- /dev/null +++ b/packages/api/src/infrastructure/harness-eval/skip-reason-eligibility.ts @@ -0,0 +1,142 @@ +/** + * F257 V2 — skip-reason escalation eligibility registry. + * + * Sol verdict `2026-07-21-harness-ledger-dedup-active-false-escalation-c3`: + * `checkGuardThreshold` counted ALL `a2a_route_decision_skip` episodes + * toward 3/7d harmful-rejection escalation, but `dedup_active` is a + * HEALTHY delivery-dedup mechanism (cat already processing, skip is + * correct behavior). Escalating it misclassifies normal operation as harm. + * + * This registry declares which skip reasons are ELIGIBLE for harmful- + * rejection escalation. Classification authority belongs to the PRODUCER + * (`routing-decision.ts` defines the reason enum), not the escalation + * layer — Fable architecture ruling. + * + * Design: declarative data (not control flow), same pattern as + * `guard-ledger-registry.ts`. Null-prototype + deep-frozen for immutability. + * + * Sol R2 P2-1: compile-time exhaustive against producer union. Adding a + * reason to RoutingDecision without updating this registry is a compile + * error (satisfies Record). + * + * Sol R3 P2-1: `pingpong_streak` is now a producer-typed reason on the + * `block_pingpong` action in routing-decision.ts (no longer hand-written + * SyntheticSkipReason). Both `skip.reason` and `block_pingpong.reason` + * are extracted from the RoutingDecision union. + * + * [宪宪/claude-opus-4-6🐾] + */ + +import type { RoutingDecision } from '../../domains/cats/services/agents/routing/routing-decision.js'; + +// --------------------------------------------------------------------------- +// Skip-reason classification +// --------------------------------------------------------------------------- + +/** + * Category for observability — what kind of skip this is. + * - 'delivery_dedup': healthy re-delivery suppression (cat already active) + * - 'safety_guard': harmful pattern blocked (pingpong, depth loops) + * - 'abort': user/system-initiated abort + */ +export type SkipReasonCategory = 'delivery_dedup' | 'safety_guard' | 'abort'; + +export interface SkipReasonEntry { + /** Whether this reason counts toward harmful-rejection escalation. */ + readonly eligible: boolean; + /** Observability classification. */ + readonly category: SkipReasonCategory; + /** Human-readable explanation for verdict/bundle attribution. */ + readonly description: string; +} + +// --------------------------------------------------------------------------- +// Compile-time exhaustiveness (sol R2 P2-1) +// --------------------------------------------------------------------------- + +/** Skip reasons from routing-decision.ts producer union (after queue_pending removal). */ +type RoutingSkipReason = Extract['reason']; + +/** + * Sol R3 P2-1: block_pingpong reason is now part of the RoutingDecision + * union (producer-defined), not a hand-written synthetic string. Extracted + * the same way as skip reasons — compile-time bound to the producer type. + */ +type RoutingBlockReason = Extract['reason']; + +/** + * Union of ALL actually-emitted skip reasons from all producers. + * Registry must classify every member — `satisfies` enforces this at compile time. + * Both `skip` and `block_pingpong` actions carry typed `reason` fields; + * adding a new reason without updating this registry is a compile error. + */ +export type EmittedSkipReason = RoutingSkipReason | RoutingBlockReason; + +// --------------------------------------------------------------------------- +// Registry (sol R1 P3-1: deep-frozen; sol R2 P2-1: exhaustive) +// --------------------------------------------------------------------------- + +/** + * Known entries — compile-time exhaustive over EmittedSkipReason. + * If a producer adds a new reason, TypeScript fails here until classified. + */ +const knownEntries = { + dedup_active: Object.freeze({ + eligible: false, + category: 'delivery_dedup' as const, + description: 'Cat already processing in InvocationQueue — skip is correct delivery dedup, not a harmful rejection.', + }), + depth: Object.freeze({ + eligible: true, + category: 'safety_guard' as const, + description: 'A2A chain depth limit reached — may indicate runaway mention loops (chain safety guard).', + }), + aborted: Object.freeze({ + eligible: false, + category: 'abort' as const, + description: 'User or system abort — intentional cancellation, not a guard rejection.', + }), + pingpong_streak: Object.freeze({ + eligible: true, + category: 'safety_guard' as const, + description: 'A2A pingpong streak blocked — harmful bidirectional loop.', + }), +} satisfies Record; + +/** Null-prototype + frozen: prototype keys can't collide, entries can't mutate. */ +const entries: Record = Object.assign( + Object.create(null) as Record, + knownEntries, +); + +export const SKIP_REASON_ELIGIBILITY: Readonly> = Object.freeze(entries); + +// --------------------------------------------------------------------------- +// Query API +// --------------------------------------------------------------------------- + +/** + * Is a skip reason eligible for harmful-rejection escalation? + * + * Unknown reasons default to ELIGIBLE (fail-closed: a new reason that + * nobody classified yet should still escalate — false positive is safer + * than silent suppression of a new harmful pattern). + */ +export function isEscalationEligible(normalizedReason: string | undefined): boolean { + if (!normalizedReason) return true; // missing reason → eligible (fail-closed) + const entry = Object.hasOwn(SKIP_REASON_ELIGIBILITY, normalizedReason) + ? SKIP_REASON_ELIGIBILITY[normalizedReason] + : undefined; + return entry ? entry.eligible : true; // unknown reason → eligible (fail-closed) +} + +/** + * Get the category for a skip reason (observability / bundle breakdown). + * Returns 'unknown' for unregistered reasons. + */ +export function skipReasonCategory(normalizedReason: string): SkipReasonCategory | 'unknown' { + const entry = Object.hasOwn(SKIP_REASON_ELIGIBILITY, normalizedReason) + ? SKIP_REASON_ELIGIBILITY[normalizedReason] + : undefined; + return entry ? entry.category : 'unknown'; +} diff --git a/packages/api/src/infrastructure/harness-eval/task-outcome/magic-word-metric.ts b/packages/api/src/infrastructure/harness-eval/task-outcome/magic-word-metric.ts new file mode 100644 index 0000000000..6bd09386f3 --- /dev/null +++ b/packages/api/src/infrastructure/harness-eval/task-outcome/magic-word-metric.ts @@ -0,0 +1,306 @@ +/** + * F257 V1 — magic word 词面出现数 (T-B §3.5 of the F257 redesign). + * + * The metric is a READ-ONLY projection of Event Memory (single source of truth, + * 归一裁定 2026-06-06) — this module writes NO second store. What it does write + * is Event Memory itself, via the T-B collection-integrity contract: the live + * path (`void tryDetectMagicWords`) can drop hits silently, so BEFORE computing + * the metric we re-scan the window's user-authored messages with the same pure + * detector and backfill missing events idempotently (markEvent is atomic on + * UNIQUE(owner, threadId, messageId, word)). Reconcile failure → the window is + * unmeasurable. A persisted owner-scoped high-watermark records scan progress. + * + * 口径 (T-B): raw substring hits, unique per (message, word) — NOT interpreted + * as governance brakes; graded 拉闸数 is a future capability outside this module. + */ + +import type { CatId, ConnectorSource, EventMemoryRecord } from '@cat-cafe/shared'; +import type { RedisClient } from '@cat-cafe/shared/utils'; +import { + isAuthenticatedOperatorMessage, + type MessageProvenance, +} from '../../../domains/cats/services/stores/ports/MessageStore.js'; +import { parsePersistedMessageRecord } from '../../../domains/cats/services/stores/redis/redis-message-parsers.js'; +import { MessageKeys } from '../../../domains/cats/services/stores/redis-keys/message-keys.js'; +import type { IEventMemoryStore } from '../../../domains/memory/EventMemoryStore.js'; +import { createModuleLogger } from '../../logger.js'; +import { detectMagicWords, MAGIC_WORD_PATTERNS } from './magic-word-detector.js'; + +const log = createModuleLogger('magic-word-metric'); + +const MAGIC_WORD_WATERMARK_KEY = (ownerUserId: string) => `magic-word:reconcile-watermark:${ownerUserId}`; + +/** Advance a numeric watermark only forward. */ +const NUMERIC_WATERMARK_LUA = ` +local cur = tonumber(redis.call('GET', KEYS[1])) +local nxt = tonumber(ARGV[1]) +if (not cur) or (nxt > cur) then + redis.call('SET', KEYS[1], ARGV[1]) + return 1 +end +return 0 +`; + +const EXCERPT_MAX = 200; + +export interface MagicWordReconcileResult { + ok: boolean; + /** user-authored messages scanned in the window */ + scanned: number; + /** events newly inserted by this reconcile (live path had missed them) */ + backfilled: number; +} + +export type MagicWordCountsResult = + | { unmeasurable: true; reason: 'reconcile_failed' | 'read_failed' } + | { + unmeasurable: false; + window: { fromTs: number; toTs: number }; + reconcile: MagicWordReconcileResult; + /** unique (message, word) hit count per word — T-B raw口径 */ + counts: Record; + total: number; + }; + +interface ScannableMessage { + id: string; + threadId: string; + catId: CatId | null; + content: string; + mentions: readonly CatId[]; + effectiveOrderAt: number; + source?: ConnectorSource; + provenance?: MessageProvenance; +} + +export class MagicWordMetricService { + private readonly redis: RedisClient; + private readonly eventMemoryStore: IEventMemoryStore; + + constructor(deps: { redis: RedisClient; eventMemoryStore: IEventMemoryStore }) { + this.redis = deps.redis; + this.eventMemoryStore = deps.eventMemoryStore; + } + + private async readWindowMessages(ownerUserId: string, fromTs: number, toTs: number): Promise { + const entries = await this.redis.zrangebyscore(MessageKeys.user(ownerUserId), fromTs, toTs, 'WITHSCORES'); + if (entries.length === 0) return []; + const candidates: Array<{ id: string; score: string }> = []; + for (let index = 0; index + 1 < entries.length; index += 2) { + candidates.push({ id: entries[index] as string, score: entries[index + 1] as string }); + } + const pipeline = this.redis.pipeline(); + for (const candidate of candidates) { + pipeline.hmget( + MessageKeys.detail(candidate.id), + 'id', + 'threadId', + 'userId', + 'catId', + 'content', + 'mentions', + 'timestamp', + 'deliveredAt', + 'deletedAt', + 'deletedBy', + '_tombstone', + 'source', + 'routingFact', + 'provenance', + ); + } + const results = await pipeline.exec(); + if (!results || results.length !== candidates.length) { + throw new Error('magic-word reconcile: pipeline result shape mismatch'); + } + const messages: ScannableMessage[] = []; + for (let index = 0; index < results.length; index += 1) { + const entry = results[index]; + const candidate = candidates[index]; + const [err, value] = entry as [Error | null, Array]; + if (err) throw err; + const [ + id, + threadId, + userId, + catId, + content, + mentions, + timestamp, + deliveredAt, + deletedAt, + deletedBy, + tombstone, + source, + routingFact, + provenance, + ] = value; + const parsed = parsePersistedMessageRecord({ + expectedId: candidate.id, + expectedOwnerUserId: ownerUserId, + expectedTimelineScore: candidate.score, + id, + threadId, + userId, + catId, + content, + mentions, + timestamp, + deliveredAt, + deletedAt, + deletedBy, + tombstone, + source, + routingFact, + provenance, + }); + if (parsed.state === 'missing') { + // sol R1 P1-4: an indexed message whose hash is gone is a collection + // gap — skipping it silently would let the metric report a partial + // window as fully reconciled. + throw new Error('magic-word reconcile: indexed message hash missing'); + } + if (parsed.state === 'invalid') { + throw new Error(`magic-word reconcile: invalid persisted message record (${parsed.reason})`); + } + if (parsed.state === 'deleted') continue; + const record = parsed.record; + messages.push({ + id: record.id, + threadId: record.threadId, + catId: record.catId, + content: record.content, + mentions: record.mentions, + effectiveOrderAt: record.effectiveOrderAt, + ...(record.source ? { source: record.source } : {}), + ...(parsed.state === 'present' ? { provenance: parsed.provenance } : {}), + }); + } + return messages; + } + + private backfillMessageHits(msg: ScannableMessage, ownerUserId: string): number { + const hits = detectMagicWords(msg.content); + if (hits.length === 0) return 0; + const firstMention = msg.mentions[0] ?? null; + const excerpt = msg.content.length > EXCERPT_MAX ? `${msg.content.slice(0, EXCERPT_MAX)}…` : msg.content; + const seenWords = new Set(); + let backfilled = 0; + for (const hit of hits) { + if (seenWords.has(hit.word)) continue; // unique per (message, word) — same as the store key + seenWords.add(hit.word); + const record: EventMemoryRecord = { + type: hit.word, + trigger: 'human_brake', + cat: firstMention ?? 'unknown', + threadId: msg.threadId, + messageId: msg.id, + // Same coordinate as owner timeline membership: queued messages move + // to their delivery position while immediate messages stay at send time. + timestamp: msg.effectiveOrderAt, + summary: excerpt, + cognitiveTransition: 'user_brake', + relatedHarness: null, + confidence: 'high', + }; + const result = this.eventMemoryStore.markEvent(record, ownerUserId); + if (result.inserted) backfilled += 1; + } + return backfilled; + } + + /** Shared scan core — throws on any collection gap (callers map to ok:false). */ + private async scanAndBackfill( + ownerUserId: string, + fromTs: number, + toTs: number, + ): Promise<{ scanned: number; backfilled: number; userMessages: ScannableMessage[] }> { + const messages = await this.readWindowMessages(ownerUserId, fromTs, toTs); + let scanned = 0; + let backfilled = 0; + const userMessages: ScannableMessage[] = []; + for (const msg of messages) { + // T-B selects original observations on the AUTHOR axis: every real + // operator-authored original counts whether or not it went through a + // routing parser (e.g. game-lane user messages). Cat/system rows and + // storage-derived branch/import copies do not create operator behavior + // observations. Routing provenance remains a separate axis. + // sol R4 P1-1c: 'absent' = legacy pre-contract message, honestly out of + // cohort; 'malformed' = corrupt declaration, cohort membership unknowable + // — a collection gap, so the window must read unmeasurable, not smaller. + if (!isAuthenticatedOperatorMessage(msg)) { + continue; + } + scanned += 1; + userMessages.push(msg); + backfilled += this.backfillMessageHits(msg, ownerUserId); + } + await this.redis.eval(NUMERIC_WATERMARK_LUA, 1, MAGIC_WORD_WATERMARK_KEY(ownerUserId), String(toTs)); + return { scanned, backfilled, userMessages }; + } + + /** + * T-B collection-integrity contract: idempotently re-scan the window's + * user-authored messages with the pure detector and backfill Event Memory. + * Cat-authored messages are out of cohort (magic words are operator brakes). + */ + async reconcileWindow(ownerUserId: string, fromTs: number, toTs: number): Promise { + try { + const scan = await this.scanAndBackfill(ownerUserId, fromTs, toTs); + return { ok: true, scanned: scan.scanned, backfilled: scan.backfilled }; + } catch (error) { + log.error({ error, ownerUserId }, 'magic-word reconcile failed'); + return { ok: false, scanned: 0, backfilled: 0 }; + } + } + + /** + * T-B active-V1 metric: unique (message, word) hit counts per word over a + * reconciled window — a read-only projection of Event Memory. + * + * sol R1/R7: window membership is a JOIN on message coordinates, never an + * event-timestamp filter. Live events may land after an immediate message or + * before a queued message's later delivery position, so either time-side + * prefilter can drop a legitimate hit. + */ + async computeWordCounts(ownerUserId: string, fromTs: number, toTs: number): Promise { + let scan: { scanned: number; backfilled: number; userMessages: ScannableMessage[] }; + try { + scan = await this.scanAndBackfill(ownerUserId, fromTs, toTs); + } catch (error) { + log.error({ error, ownerUserId }, 'magic-word reconcile failed'); + return { unmeasurable: true, reason: 'reconcile_failed' }; + } + const reconcile: MagicWordReconcileResult = { ok: true, scanned: scan.scanned, backfilled: scan.backfilled }; + + try { + const magicWords = new Set(MAGIC_WORD_PATTERNS); + const counts: Record = {}; + let total = 0; + for (const msg of scan.userMessages) { + const events = this.eventMemoryStore.getByCoord(msg.threadId, msg.id, ownerUserId); + for (const event of events) { + if (event.trigger !== 'human_brake') continue; + if (!magicWords.has(event.type)) continue; + counts[event.type] = (counts[event.type] ?? 0) + 1; + total += 1; + } + } + return { unmeasurable: false, window: { fromTs, toTs }, reconcile, counts, total }; + } catch (error) { + log.error({ error, ownerUserId }, 'magic-word metric read failed'); + return { unmeasurable: true, reason: 'read_failed' }; + } + } + + /** Collection-health snapshot: how far the reconcile watermark has advanced. */ + async getWatermark(ownerUserId: string): Promise { + try { + const raw = await this.redis.get(MAGIC_WORD_WATERMARK_KEY(ownerUserId)); + return raw === null ? null : Number.parseInt(raw, 10); + } catch (error) { + log.error({ error, ownerUserId }, 'magic-word watermark read failed'); + return null; + } + } +} diff --git a/packages/api/src/infrastructure/harness-eval/task-outcome/task-outcome-signal-wiring.ts b/packages/api/src/infrastructure/harness-eval/task-outcome/task-outcome-signal-wiring.ts index 5c41ce9d69..58129e83b1 100644 --- a/packages/api/src/infrastructure/harness-eval/task-outcome/task-outcome-signal-wiring.ts +++ b/packages/api/src/infrastructure/harness-eval/task-outcome/task-outcome-signal-wiring.ts @@ -91,6 +91,9 @@ export function appendMagicWordRefToEpisode( store: TaskOutcomeEpisodeStore, input: MagicWordRefInput, ): SignalWiringResult { + if (!store.canAppendMagicWordRef(input.threadId, input.eventId)) { + return { episodeId: store.getActiveEpisode(input.threadId)?.episodeId ?? '', signalAppended: false }; + } const ep = store.getActiveEpisode(input.threadId) ?? store.createEpisode({ @@ -99,7 +102,7 @@ export function appendMagicWordRefToEpisode( participants: input.catId ? [input.catId] : [], }); - store.appendSignal(ep.episodeId, { + const signalAppended = store.appendMagicWordRefSignal(ep.episodeId, { category: 'a2', record: { type: 'magic_word_ref', @@ -111,7 +114,7 @@ export function appendMagicWordRefToEpisode( }, }); - return { episodeId: ep.episodeId, signalAppended: true }; + return { episodeId: ep.episodeId, signalAppended }; } // ---- Cancel burst check → proxy signal ---- diff --git a/packages/api/src/infrastructure/harness-eval/task-outcome/task-outcome-store.ts b/packages/api/src/infrastructure/harness-eval/task-outcome/task-outcome-store.ts index 4c4e8895f3..da81649089 100644 --- a/packages/api/src/infrastructure/harness-eval/task-outcome/task-outcome-store.ts +++ b/packages/api/src/infrastructure/harness-eval/task-outcome/task-outcome-store.ts @@ -106,6 +106,15 @@ export class TaskOutcomeEpisodeStore { CREATE INDEX IF NOT EXISTS idx_signals_episodeId ON task_outcome_signals(episodeId); + + CREATE TABLE IF NOT EXISTS task_outcome_deleted_magic_events ( + eventId TEXT PRIMARY KEY, + deletedAt INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS task_outcome_deleted_magic_threads ( + threadId TEXT PRIMARY KEY, + deletedAt INTEGER NOT NULL + ); `); } @@ -149,6 +158,22 @@ export class TaskOutcomeEpisodeStore { } appendSignal(episodeId: string, input: AppendSignalInput): void { + const coordinate = this.readMagicWordRefCoordinate(input); + if (coordinate) { + this.db.transaction(() => { + if (!this.magicWordRefWritable(coordinate.threadId, coordinate.eventId)) { + throw new Error( + `TaskOutcomeEpisodeStore: deleted magic_word_ref write rejected (${coordinate.threadId}/${coordinate.eventId})`, + ); + } + this.insertSignal(episodeId, input); + })(); + return; + } + this.insertSignal(episodeId, input); + } + + private insertSignal(episodeId: string, input: AppendSignalInput): void { const now = new Date().toISOString(); this.db .prepare( @@ -158,6 +183,91 @@ export class TaskOutcomeEpisodeStore { .run(episodeId, input.category, JSON.stringify(input.record), now); } + canAppendMagicWordRef(threadId: string, eventId: string): boolean { + return this.magicWordRefWritable(threadId, eventId); + } + + appendMagicWordRefSignal(episodeId: string, input: AppendSignalInput): boolean { + const coordinate = this.readMagicWordRefCoordinate(input); + if (!coordinate) throw new Error('TaskOutcomeEpisodeStore: expected magic_word_ref signal'); + return this.db.transaction(() => { + if (!this.magicWordRefWritable(coordinate.threadId, coordinate.eventId)) return false; + this.insertSignal(episodeId, input); + return true; + })(); + } + + deleteMagicWordRefsByEventIds(eventIds: readonly string[]): number { + const deletedEventIds = [...new Set(eventIds.filter((eventId) => eventId.length > 0))]; + if (deletedEventIds.length === 0) return 0; + return this.db.transaction(() => { + const fence = this.db.prepare( + `INSERT INTO task_outcome_deleted_magic_events (eventId, deletedAt) + VALUES (?, ?) + ON CONFLICT(eventId) DO NOTHING`, + ); + const now = Date.now(); + for (const eventId of deletedEventIds) fence.run(eventId, now); + const deletedSet = new Set(deletedEventIds); + return this.deleteMagicWordRefSignals( + (record) => typeof record.eventId === 'string' && deletedSet.has(record.eventId), + ); + })(); + } + + deleteMagicWordRefsByThread(threadId: string): number { + return this.db.transaction(() => { + this.db + .prepare( + `INSERT INTO task_outcome_deleted_magic_threads (threadId, deletedAt) + VALUES (?, ?) + ON CONFLICT(threadId) DO NOTHING`, + ) + .run(threadId, Date.now()); + return this.deleteMagicWordRefSignals((record) => record.threadId === threadId); + })(); + } + + private magicWordRefWritable(threadId: string, eventId: string): boolean { + const deletedThread = this.db + .prepare('SELECT 1 FROM task_outcome_deleted_magic_threads WHERE threadId = ? LIMIT 1') + .get(threadId); + if (deletedThread) return false; + const deletedEvent = this.db + .prepare('SELECT 1 FROM task_outcome_deleted_magic_events WHERE eventId = ? LIMIT 1') + .get(eventId); + return !deletedEvent; + } + + private readMagicWordRefCoordinate(input: AppendSignalInput): { threadId: string; eventId: string } | null { + if (input.record.type !== 'magic_word_ref') return null; + const threadId = input.record.threadId; + const eventId = input.record.eventId; + if (typeof threadId !== 'string' || threadId.length === 0 || typeof eventId !== 'string' || eventId.length === 0) { + throw new Error('TaskOutcomeEpisodeStore: malformed magic_word_ref write rejected'); + } + return { threadId, eventId }; + } + + private deleteMagicWordRefSignals(matches: (record: Record) => boolean): number { + const rows = this.db.prepare("SELECT id, record FROM task_outcome_signals WHERE category = 'a2'").all() as Array<{ + id: number; + record: string; + }>; + const ids: number[] = []; + for (const row of rows) { + try { + const record = JSON.parse(row.record) as Record; + if (record.type === 'magic_word_ref' && matches(record)) ids.push(row.id); + } catch { + // Malformed unrelated legacy signal is not evidence that this deletion failed. + } + } + if (ids.length === 0) return 0; + const placeholders = ids.map(() => '?').join(', '); + return this.db.prepare(`DELETE FROM task_outcome_signals WHERE id IN (${placeholders})`).run(...ids).changes; + } + getSignals(episodeId: string): StoredSignal[] { const rows = this.db .prepare('SELECT * FROM task_outcome_signals WHERE episodeId = ? ORDER BY id ASC') diff --git a/packages/api/src/infrastructure/scheduler/DynamicTaskStore.ts b/packages/api/src/infrastructure/scheduler/DynamicTaskStore.ts index 8c1316be94..e4397806ac 100644 --- a/packages/api/src/infrastructure/scheduler/DynamicTaskStore.ts +++ b/packages/api/src/infrastructure/scheduler/DynamicTaskStore.ts @@ -12,6 +12,8 @@ export interface DynamicTaskDef { enabled: boolean; createdBy: string; createdAt: string; + /** F257: number of RUN_FAILED retries already attempted for once-tasks (durable across restarts) */ + retryAttempts: number; } /** CRUD store for dynamic task definitions (Phase 3A AC-G3) */ @@ -21,8 +23,8 @@ export class DynamicTaskStore { insert(def: DynamicTaskDef): void { this.db .prepare( - `INSERT INTO dynamic_task_defs (id, template_id, trigger_json, params_json, display_json, delivery_thread_id, enabled, created_by, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO dynamic_task_defs (id, template_id, trigger_json, params_json, display_json, delivery_thread_id, enabled, created_by, created_at, retry_attempts) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( def.id, @@ -34,6 +36,7 @@ export class DynamicTaskStore { def.enabled ? 1 : 0, def.createdBy, def.createdAt, + def.retryAttempts ?? 0, ); } @@ -69,6 +72,18 @@ export class DynamicTaskStore { .run(JSON.stringify(trigger), id); return result.changes > 0; } + + /** + * F257: atomically persist the retry due trigger and the retry counter for a + * once-task. A single UPDATE avoids a crash between two writes leaving the row + * in an inconsistent state (e.g. future fireAt with retryAttempts=0). + */ + updateRetryState(id: string, trigger: TriggerSpec, attempts: number): boolean { + const result = this.db + .prepare('UPDATE dynamic_task_defs SET trigger_json = ?, retry_attempts = ? WHERE id = ?') + .run(JSON.stringify(trigger), attempts, id); + return result.changes > 0; + } } interface RawRow { @@ -81,6 +96,7 @@ interface RawRow { enabled: number; created_by: string; created_at: string; + retry_attempts: number; } function todef(row: RawRow): DynamicTaskDef { @@ -94,5 +110,6 @@ function todef(row: RawRow): DynamicTaskDef { enabled: row.enabled === 1, createdBy: row.created_by, createdAt: row.created_at, + retryAttempts: row.retry_attempts ?? 0, }; } diff --git a/packages/api/src/infrastructure/scheduler/TaskRunnerV2.ts b/packages/api/src/infrastructure/scheduler/TaskRunnerV2.ts index edd471d4c8..89cf7641f2 100644 --- a/packages/api/src/infrastructure/scheduler/TaskRunnerV2.ts +++ b/packages/api/src/infrastructure/scheduler/TaskRunnerV2.ts @@ -49,6 +49,12 @@ export interface TaskRunnerV2Options { * invocationTracker/queueProcessor are constructed after the runner. */ isThreadBusy?: (threadId: string) => boolean; + /** + * sol P1 regression收口 2026-07-23: delay between RUN_FAILED retries for + * once-tasks. Injectable for tests; defaults to 30s (same as the + * governance-skip retry cadence). + */ + onceRetryDelayMs?: number; } /** Phase 2.5: Compute human-readable subject preview from subjectKind + lastRun (AC-E2) */ @@ -139,6 +145,11 @@ export class TaskRunnerV2 { private isThreadBusy: TaskRunnerV2Options['isThreadBusy']; /** F167 Phase M: per-task consecutive defer counter (reset on fire) */ private deferCounts = new Map(); + /** sol P1收口: per-task RUN_FAILED retry counter for once-tasks (reset on registration) */ + private runFailedRetries = new Map(); + private readonly onceRetryDelayMs: number; + /** Bounded RUN_FAILED retries before a once-task is retired — never silent-drop on first failure. */ + private static readonly MAX_RUN_FAILED_RETRIES = 3; constructor(opts: TaskRunnerV2Options) { this.logger = opts.logger; @@ -153,6 +164,7 @@ export class TaskRunnerV2 { this.notifyLifecycle = opts.notifyLifecycle; this.dynamicTaskStore = opts.dynamicTaskStore; this.isThreadBusy = opts.isThreadBusy; + this.onceRetryDelayMs = opts.onceRetryDelayMs ?? 30_000; } /** Late-bind invokeTrigger (constructed after TaskRunnerV2 in boot sequence) */ @@ -224,7 +236,11 @@ export class TaskRunnerV2 { let loaded = 0; for (const def of defs) { // #415: once tasks with past fireAt → missed window, cancel + notify + retire - if (def.trigger.type === 'once' && def.trigger.fireAt < Date.now()) { + // F257: if the task has persisted retryAttempts > 0, it is a once-task that + // was mid-backoff when the process stopped; resume the retry instead of + // treating the expired due time as a missed window. scheduleOnceTick will + // fire immediately because remaining=0. + if (def.trigger.type === 'once' && def.trigger.fireAt < Date.now() && def.retryAttempts <= 0) { this.handleMissedOnceTask(def, store); continue; } @@ -241,6 +257,10 @@ export class TaskRunnerV2 { }); // Override display with persisted display spec.display = def.display; + // F257: restore retry progress for once-tasks that were mid-backoff when the process stopped. + if (def.retryAttempts > 0) { + this.runFailedRetries.set(def.id, def.retryAttempts); + } try { this.registerDynamic(spec, def.id); loaded++; @@ -403,6 +423,53 @@ export class TaskRunnerV2 { }, 30_000); if (typeof retryTimer === 'object' && 'unref' in retryTimer) retryTimer.unref(); this.timers.set(task.id, retryTimer); + } else if (lastOutcome === 'RUN_FAILED') { + // sol P1 regression收口 2026-07-23: a failed once-task must not be + // silently retired — the 2026-07-20→23 incident lost 22 hold-ball + // wakes this way. Bounded retry covers transient failures for tasks + // that declare supportsOnceRetry (delivery is idempotent via + // DeliverOpts.idempotencyKey); non-retry-safe once-tasks are retired + // immediately to avoid duplicating side-effects. After exhaustion we + // still retire, but loudly — the durable-failed lifecycle + // (persistent failed state + operator surface) remains an open + // design item tracked in the F257 review thread. + if (!task.supportsOnceRetry) { + this.logger.error( + `[scheduler] ${task.id}: once task RUN_FAILED but task is not retry-safe — retiring immediately`, + ); + this.retireOnceTask(task.id); + return; + } + const attempt = (this.runFailedRetries.get(task.id) ?? 0) + 1; + if (attempt <= TaskRunnerV2.MAX_RUN_FAILED_RETRIES) { + this.runFailedRetries.set(task.id, attempt); + // F257: atomically persist the retry due time and counter BEFORE the + // backoff timer fires. A single UPDATE keeps trigger_json and + // retry_attempts consistent if the process crashes mid-write. On + // restart, hydrateDynamic() sees a future fireAt (or a past fireAt + // with retryAttempts>0) and resumes the countdown instead of treating + // it as a missed window. + if (task.trigger.type === 'once') { + task.trigger.fireAt = Date.now() + this.onceRetryDelayMs; + this.dynamicTaskStore?.updateRetryState(task.id, task.trigger, attempt); + } + this.logger.error( + `[scheduler] ${task.id}: once task RUN_FAILED, retry ${attempt}/${TaskRunnerV2.MAX_RUN_FAILED_RETRIES} in ${this.onceRetryDelayMs}ms`, + ); + const retryTimer = setTimeout(() => { + if (!this.started || !this.tasks.some((t) => t.id === task.id)) return; + if (task.trigger.type !== 'once') return; // re-narrow TriggerSpec inside closure + this.scheduleOnceTick(task); + }, this.onceRetryDelayMs); + if (typeof retryTimer === 'object' && 'unref' in retryTimer) retryTimer.unref(); + this.timers.set(task.id, retryTimer); + } else { + this.runFailedRetries.delete(task.id); + this.logger.error( + `[scheduler] ${task.id}: once task RUN_FAILED ${TaskRunnerV2.MAX_RUN_FAILED_RETRIES}x — retiring; delivery permanently failed (see ledger)`, + ); + this.retireOnceTask(task.id); + } } else { this.retireOnceTask(task.id); } @@ -417,6 +484,7 @@ export class TaskRunnerV2 { /** #415: Remove a once-task from runtime + persistent store after execution */ private retireOnceTask(taskId: string): void { + this.runFailedRetries.delete(taskId); // Use taskId directly — for dynamic tasks, taskId === dynDefId if (this.dynamicTaskStore) { this.dynamicTaskStore.remove(taskId); diff --git a/packages/api/src/infrastructure/scheduler/delivery.ts b/packages/api/src/infrastructure/scheduler/delivery.ts index 848d5d86f4..c90552029b 100644 --- a/packages/api/src/infrastructure/scheduler/delivery.ts +++ b/packages/api/src/infrastructure/scheduler/delivery.ts @@ -3,14 +3,22 @@ * Templates call deliver() to post messages to threads without going through MCP callbacks. */ import { randomUUID } from 'node:crypto'; +import type { IMessageStore } from '../../domains/cats/services/stores/ports/MessageStore.js'; import type { DeliverOpts, ScheduleLifecycleNotice } from './types.js'; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type AnyFn = (...args: any[]) => any; - export interface DeliveryDeps { - messageStore: { append: AnyFn }; - socketManager: { broadcastToRoom: AnyFn; emitToUser: AnyFn }; + /** + * Real append contract (sol P1 regression 2026-07-23): the previous + * `append: AnyFn` hid the required `provenance` field from the compiler, + * so this writer silently violated the write-boundary contract and every + * scheduled delivery failed at runtime. Typing the real port makes the + * compiler enforce what the store asserts. + */ + messageStore: Pick; + socketManager: { + broadcastToRoom(room: string, event: string, data: unknown): void; + emitToUser(userId: string, event: string, data: unknown): void; + }; } export const SCHEDULER_SOURCE = { @@ -22,6 +30,8 @@ export const SCHEDULER_SOURCE = { export function createDeliverFn(deps: DeliveryDeps): (opts: DeliverOpts) => Promise { return async (opts: DeliverOpts): Promise => { const stored = await deps.messageStore.append({ + // System-synthesized schedule output; no parser lane runs over it. + provenance: { author: 'system', routed: false, observation: 'original' }, userId: opts.userId, catId: null, content: opts.content, @@ -31,6 +41,7 @@ export function createDeliverFn(deps: DeliveryDeps): (opts: DeliverOpts) => Prom threadId: opts.threadId, source: SCHEDULER_SOURCE, ...(opts.extra ? { extra: opts.extra } : {}), + ...(opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : {}), }); const schedulerExtra = stored.extra?.scheduler ?? opts.extra?.scheduler; deps.socketManager.broadcastToRoom(`thread:${opts.threadId}`, 'connector_message', { diff --git a/packages/api/src/infrastructure/scheduler/templates/reminder.ts b/packages/api/src/infrastructure/scheduler/templates/reminder.ts index 57f541e797..6f66fe459a 100644 --- a/packages/api/src/infrastructure/scheduler/templates/reminder.ts +++ b/packages/api/src/infrastructure/scheduler/templates/reminder.ts @@ -20,6 +20,7 @@ export const reminderTemplate: TaskTemplate = { const targetCatId = (p.params.targetCatId as string) || null; const triggerUserId = (p.params.triggerUserId as string) || 'default-user'; const threadId = p.deliveryThreadId; + const isHoldBallWake = instanceId.startsWith('hold-ball-'); // F167 Phase M (codex P1): pre-fire defer activation is hold_ball-specific. // Gate on the `hold-ball-` instanceId prefix — callback-hold-ball-routes mints those // ids, while public /api/schedule/tasks only mints `dyn-*` (schedule.ts:417), so a @@ -27,11 +28,12 @@ export const reminderTemplate: TaskTemplate = { // Defer tuning (interval/maxDefers) is NOT read from public params — it uses // TaskRunnerV2 internal defaults — so a deferIntervalMs:0 + huge maxDefers churn // attack via /api/schedule/tasks is structurally impossible. - const deferWhileThreadBusy = p.params.deferWhileThreadBusy === true && instanceId.startsWith('hold-ball-'); + const deferWhileThreadBusy = p.params.deferWhileThreadBusy === true && isHoldBallWake; return { id: instanceId, profile: 'awareness', trigger: p.trigger, + supportsOnceRetry: true, ...(deferWhileThreadBusy && threadId ? { firePolicy: { deferWhileThreadBusy: true, threadId } } : {}), admission: { async gate() { @@ -48,18 +50,22 @@ export const reminderTemplate: TaskTemplate = { const catId = targetCatId ?? ctx.assignedCatId ?? 'opus'; const content = `${SCHEDULER_TRIGGER_PREFIX} ${message}`; - if (instanceId.startsWith('hold-ball-') && p.trigger.type === 'once' && threadId) { + if (isHoldBallWake && p.trigger.type === 'once' && threadId) { ctx.ballCustody ?.record(buildHoldExpiredEvent({ threadId: tid, catId, fireAt: p.trigger.fireAt, at: Date.now() })) .catch(() => {}); } - // Store trigger message first → real messageId for InvocationRecord + retry + // Store trigger message first → real messageId for InvocationRecord + retry. + // Once-triggers get a bounded RUN_FAILED retry in TaskRunnerV2 — the + // per-instance idempotency key makes a retried append return the + // original message instead of duplicating it. const messageId = await ctx.deliver({ threadId: tid, content, userId: 'scheduler', ...(ctx.invokeTrigger ? { extra: { scheduler: { hiddenTrigger: true } } } : {}), + ...(p.trigger.type === 'once' ? { idempotencyKey: `reminder:${instanceId}` } : {}), }); // Wake a cat to act on the trigger message @@ -68,6 +74,7 @@ export const reminderTemplate: TaskTemplate = { void Promise.resolve( ctx.invokeTrigger.trigger(tid, catId, triggerUserId, content, messageId, undefined, { sourceCategory: 'scheduled', + ...(isHoldBallWake ? { completionRequirement: 'action-or-routing-exit' as const } : {}), }), ).catch(() => {}); } catch { diff --git a/packages/api/src/infrastructure/scheduler/types.ts b/packages/api/src/infrastructure/scheduler/types.ts index d94101b723..7d6f6f90fb 100644 --- a/packages/api/src/infrastructure/scheduler/types.ts +++ b/packages/api/src/infrastructure/scheduler/types.ts @@ -1,5 +1,6 @@ import type { SchedulerLifecycleEvent, SchedulerMessageExtra, SchedulerToastPayload } from '@cat-cafe/shared'; import type { IBallCustodyIngest } from '../../domains/ball-custody/BallCustodyIngest.js'; +import type { CompletionRequirement } from '../../domains/cats/services/agents/routing/route-helpers.js'; export type { SchedulerLifecycleEvent, SchedulerMessageExtra, SchedulerToastPayload } from '@cat-cafe/shared'; @@ -83,6 +84,14 @@ export interface DeliverOpts { content: string; userId: string; extra?: SchedulerMessageExtra; + /** + * Retry-safety token (sol P1 regression收口 2026-07-23): once-tasks get a + * bounded RUN_FAILED retry, so the append must be idempotent per logical + * firing. Callers firing exactly once (once-trigger tasks) pass a stable + * per-instance key; recurring tasks must omit it (each slot is a distinct + * firing). + */ + idempotencyKey?: string; } /** Phase 4: result of fetching web content */ @@ -100,6 +109,7 @@ export interface ScheduleTriggerPolicy { readonly reason?: string; readonly sourceCategory?: string; readonly suggestedSkill?: string; + readonly completionRequirement?: CompletionRequirement; } export interface ScheduleLifecycleNotice { @@ -189,6 +199,12 @@ export interface TaskSpec_P1 { context?: ContextSpec; /** Phase 2.5: display metadata — label, category, description, subjectKind (AC-E1) */ display?: TaskDisplayMeta; + /** + * F257: whether this task supports bounded RUN_FAILED retry for once-triggers. + * Only templates that provide a stable per-instance idempotency key for delivery + * may opt in; retrying a non-idempotent once-task can duplicate side-effects. + */ + supportsOnceRetry?: boolean; } /** Run ledger stats summary */ diff --git a/packages/api/src/infrastructure/telemetry/instruments.ts b/packages/api/src/infrastructure/telemetry/instruments.ts index b7e62aeb86..f3df4bc001 100644 --- a/packages/api/src/infrastructure/telemetry/instruments.ts +++ b/packages/api/src/infrastructure/telemetry/instruments.ts @@ -228,6 +228,20 @@ export const c2VoidHoldChecked = lazy(() => }), ); +// LI-005: A2A ack-liveness check — separate denominator/numerator pair +// (same pattern as void_hold_checked / void_hold_hint_emitted). +export const c2AckLivenessChecked = lazy(() => + meter().createCounter('cat_cafe.a2a.c2.ack_liveness_checked', { + description: 'C2 ack-liveness check evaluations performed (denominator for ack_liveness_hint ratio)', + }), +); + +export const c2AckLivenessHintEmitted = lazy(() => + meter().createCounter('cat_cafe.a2a.c2.ack_liveness_hint_emitted', { + description: 'C2 ack-liveness hint emitted: A2A invocation ended without routing exit or durable trigger', + }), +); + export const antigravityStreamErrorBuffered = lazy(() => meter().createCounter('cat_cafe.antigravity.stream_error.buffered_total', { description: 'Buffered Antigravity stream_error after partial text while waiting for a recovery tail', @@ -585,6 +599,8 @@ export function warmupCounters(): void { c2VerdictWithoutPassCount.add(0); c2ExitChecked.add(0); c2VoidHoldChecked.add(0); + c2AckLivenessChecked.add(0); + c2AckLivenessHintEmitted.add(0); // F231 AC-C3: profile update pipeline counters profileUpdateProposed.add(0); profileUpdateApproved.add(0); diff --git a/packages/api/src/routes/backlog.ts b/packages/api/src/routes/backlog.ts index febc3fd4c0..8f79e17291 100644 --- a/packages/api/src/routes/backlog.ts +++ b/packages/api/src/routes/backlog.ts @@ -248,6 +248,7 @@ export const backlogRoutes: FastifyPluginAsync = async (ap let kickoffMessageId = next.kickoffMessageId; if (!kickoffMessageId) { const kickoffMessage = await messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, // sol R3 P1-1 userId, catId: null, threadId, diff --git a/packages/api/src/routes/callback-auth-system-message.ts b/packages/api/src/routes/callback-auth-system-message.ts index 343d5a7a52..78c1c8448a 100644 --- a/packages/api/src/routes/callback-auth-system-message.ts +++ b/packages/api/src/routes/callback-auth-system-message.ts @@ -205,6 +205,7 @@ export class CallbackAuthSystemMessageNotifier { try { const block = buildAuthFailureBlock({ ...params, failedAt: now }); stored = await this.messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, // sol R3 P1-1 userId: params.userId, catId: null, content: `[callback-auth] ${params.tool} → ${params.reason}${params.fallbackOk ? ' (fallback ok)' : ''}`, diff --git a/packages/api/src/routes/callback-docs-routes.ts b/packages/api/src/routes/callback-docs-routes.ts index 40233a9a65..269abd026d 100644 --- a/packages/api/src/routes/callback-docs-routes.ts +++ b/packages/api/src/routes/callback-docs-routes.ts @@ -12,6 +12,7 @@ import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { FastifyPluginAsync } from 'fastify'; import { RICH_BLOCK_RULES } from '../domains/cats/services/context/rich-block-rules.js'; +import { loadObjectiveRegistry } from '../infrastructure/harness-eval/objective-registry.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -27,17 +28,47 @@ function refsPath(fileName: string): string { return resolve(__dirname, '..', '..', '..', '..', 'cat-cafe-skills', 'refs', fileName); } +/** F257 #3: resolve the objective registry YAML (docs/harness-feedback/objectives/). */ +function objectiveRegistryPath(): string { + return resolve(__dirname, '..', '..', '..', '..', 'docs', 'harness-feedback', 'objectives', 'registry.yaml'); +} + +export interface CallbackDocsRoutesOptions { + /** Test seam: override the objective registry path (defaults to the shipped location). */ + objectiveRegistryPath?: string; +} + /** * Register documentation endpoints (fallback for Skills system). * No auth required — these return static reference text. */ -export const registerCallbackDocsRoutes: FastifyPluginAsync = async (app) => { +export const registerCallbackDocsRoutes: FastifyPluginAsync = async (app, opts) => { + const registryPath = opts.objectiveRegistryPath ?? objectiveRegistryPath(); // Rich block usage rules app.get('/api/callbacks/rich-block-rules', async (_request, reply) => { reply.header('cache-control', 'public, max-age=3600'); return { rules: RICH_BLOCK_RULES }; }); + // F257 #3: objective registry — read-only discovery for report_harness_signal + // objectiveId (so cats stop doing archaeology). Definition layer (id/statement). + // Fail-closed (2a R1 P1-2): an unreadable/malformed/invalid catalog returns 503, + // never a cacheable empty list that would masquerade as "no objectives". + app.get('/api/callbacks/objectives', async (request, reply) => { + const result = await loadObjectiveRegistry(registryPath); + if (!result.ok) { + // 2a R2 P2-1: this endpoint is UNAUTHENTICATED. The loader's reason contains the + // registry path + fs errno — log it server-side, but return a stable, path-free 503 + // so a caller (and the MCP tool that forwards response.text()) never learns the + // install path / layout. The MCP tool still only needs to recognize the 503. + request.log.error({ reason: result.error }, '[F257] objective registry unavailable'); + reply.code(503); + return { error: 'Objective registry unavailable' }; + } + reply.header('cache-control', 'public, max-age=3600'); + return { registryVersion: result.registry.registryVersion, objectives: result.registry.objectives }; + }); + // MCP callback instructions — reads refs file (SOT moved from skill to refs/) app.get('/api/callbacks/instructions', async (_request, reply) => { try { diff --git a/packages/api/src/routes/callback-guard-rejection-routes.ts b/packages/api/src/routes/callback-guard-rejection-routes.ts new file mode 100644 index 0000000000..247168a7f3 --- /dev/null +++ b/packages/api/src/routes/callback-guard-rejection-routes.ts @@ -0,0 +1,241 @@ +/** + * F257 V2/Phase B — MCP client-layer guard rejection ingest + ledger query + * surface (AC-B1 dual entry + "queryable by ledger id"). + * + * POST: MCP-local fail-closed rejections (e.g. cross_post_message without + * routing credentials) never reach the API route that would normally emit a + * guard rejection event — the MCP layer reports them here fire-and-forget + * (fail-open client side; see packages/mcp-server/src/tools/guard-rejection-report.ts). + * + * GET: the AC-B1 acceptance step is "trigger a 429 + an MCP-local reject → + * query BY LEDGER ID returns both" — this is that consumer surface, also + * carrying AC-B2 pot stats (anomalyRefCount + how_counted). + * + * Trust boundary (V1 three-axis provenance discipline; sol review P1-1/P1-3): + * - BOTH principal kinds accepted (requireCallbackPrincipal): invocation + * principals carry trusted threadId/invocationId; agent-key principals + * carry NO thread binding — their thread coordinate is taken from the + * payload but VERIFIED through the scoped-thread resolver (owner check), + * degrading to 'unknown' (coalescer untrusted-key isolation) on any + * failure. Identity (catId/userId) always comes from the principal. + * - guardId whitelist uses Object.hasOwn — `in` walks the prototype chain + * and would accept 'toString'/'constructor' and mint function ledgerIds. + * - eventId / timestamp are server-generated (client clocks untrusted). + */ + +import { randomUUID } from 'node:crypto'; +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import type { IThreadStore } from '../domains/cats/services/stores/ports/ThreadStore.js'; +import type { GuardRejectionEventLog } from '../infrastructure/harness-eval/GuardRejectionEventLog.js'; +import { + GUARD_LEDGER_IDS, + type GuardLedgerStats, + isRegisteredGuardId, + isRegisteredLedgerId, + ledgerIdForGuard, +} from '../infrastructure/harness-eval/guard-ledger-registry.js'; +import { requireCallbackPrincipal } from './callback-auth-prehandler.js'; +import { resolveScopedThreadId } from './callback-scope-helpers.js'; + +/** Kinds the MCP client layer can legitimately produce locally. */ +const mcpGuardRejectionSchema = z.object({ + kind: z.enum(['http_schema_reject', 'http_policy_reject']), + guardId: z.string().min(1).max(120), + sourceTool: z.string().min(1).max(120), + normalizedReason: z.string().min(1).max(200), + /** + * Thread coordinate for agent-key callers (no thread binding in the + * principal). Verified via scoped-thread resolver — never trusted as-is. + * Ignored for invocation principals (their principal.threadId wins). + */ + threadId: z.string().min(1).max(200).optional(), +}); + +const ledgerQuerySchema = z.object({ + ledgerId: z.string().min(1).max(200), + sinceMs: z.coerce.number().int().positive().optional(), + untilMs: z.coerce.number().int().positive().optional(), +}); + +export interface GuardRejectionRouteDeps { + guardRejectionLog?: GuardRejectionEventLog | undefined; + /** AC-B2 pot stats — optional (absent without Redis). */ + ledgerStats?: GuardLedgerStats | undefined; + /** Scoped-thread verification for agent-key thread coordinates. */ + threadStore?: Pick | undefined; +} + +const SEVEN_DAYS_MS = 7 * 24 * 3600 * 1000; + +export function registerCallbackGuardRejectionRoutes(app: FastifyInstance, deps: GuardRejectionRouteDeps): void { + app.post('/api/callbacks/guard-rejections', async (request, reply) => { + // sol P1-1: requireCallbackPrincipal accepts BOTH invocation and + // agent-key principals — requireCallbackAuth rejected agent-key callers + // with 401, silently dropping the exact persistent-MCP scenario AC-B1 + // dual entry exists for. + const principal = requireCallbackPrincipal(request, reply); + if (!principal) return; // 401 already sent + + const parsed = mcpGuardRejectionSchema.safeParse(request.body); + if (!parsed.success) { + reply.status(400); + return { + error: 'invalid guard rejection payload', + issues: parsed.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`), + }; + } + + // sol P1-3: Object.hasOwn — `guardId in GUARD_LEDGER_IDS` accepted + // prototype keys ('toString', 'constructor') and minted function-typed + // ledgerIds into the event log and dropped ledgerId from the response. + if (!isRegisteredGuardId(parsed.data.guardId)) { + reply.status(400); + return { + error: `unregistered guardId '${parsed.data.guardId}' — register it in guard-ledger-registry first`, + registered: Object.keys(GUARD_LEDGER_IDS), + }; + } + + // Provenance per principal kind (sol P1-1 explicit contract): + // - invocation: threadId + invocationId first-hand → 'exact' + // - agent_key: payload thread coordinate verified via scoped resolver + // (owner check); verification failure degrades to 'unknown' rather than + // rejecting — the observation must not be lost, but it must never be + // attributed to a thread the caller cannot access. No invocation → 'window'. + let threadId: string; + let invocationId: string; + let correlationConfidence: 'exact' | 'window'; + if (principal.kind === 'invocation') { + threadId = principal.threadId; + invocationId = principal.invocationId; + correlationConfidence = 'exact'; + } else { + invocationId = 'unknown'; + correlationConfidence = 'window'; + if (parsed.data.threadId && deps.threadStore) { + // sol R2 P1-2: resolver infra failures (threadStore throw) must NOT + // 500 the ingest — the observation would be lost. Degrade to + // 'unknown' with a loud warning; attribution integrity is preserved + // (unknown never merges in the coalescer). + try { + const resolved = await resolveScopedThreadId( + { threadId: '', userId: principal.userId }, + parsed.data.threadId, + { threadStore: deps.threadStore }, + ); + threadId = resolved.ok ? resolved.threadId : 'unknown'; + } catch (err) { + request.log.warn( + { err, requestedThreadId: parsed.data.threadId }, + 'F257 guard-rejection ingest: scoped-thread resolver failed — degrading to unknown', + ); + threadId = 'unknown'; + } + } else { + threadId = 'unknown'; + } + } + + const ledgerId = ledgerIdForGuard(parsed.data.guardId); + const eventId = randomUUID(); + if (deps.guardRejectionLog) { + await deps.guardRejectionLog.append({ + eventId, + ledgerId, + kind: parsed.data.kind, + threadId, + catId: principal.catId as string, + guardId: parsed.data.guardId, + ownerUserId: principal.userId, + invocationId, + sourceTool: parsed.data.sourceTool, + normalizedReason: parsed.data.normalizedReason, + layer: 'mcp-client', + timestamp: Date.now(), + correlationConfidence, + }); + } + reply.status(202); + return { accepted: true, eventId, ledgerId }; + }); + + // sol P1-4: the ledgerId consumer surface. AC-B1 acceptance: rejection + // response hands the cat a ledgerId → this endpoint answers "what has this + // pot intercepted" (events across layers) + AC-B2 stats with how_counted. + app.get('/api/callbacks/guard-rejections', async (request, reply) => { + const principal = requireCallbackPrincipal(request, reply); + if (!principal) return; + + const parsed = ledgerQuerySchema.safeParse(request.query); + if (!parsed.success) { + reply.status(400); + return { + error: 'invalid ledger query', + issues: parsed.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`), + }; + } + // sol R4 P2-1: reject unregistered ledgerIds at the API boundary — without + // this check, GET accepts arbitrary `ledgerId` after only Zod length validation, + // letting spoofed pot coordinates through to the query layer. + if (!isRegisteredLedgerId(parsed.data.ledgerId)) { + reply.status(400); + return { + error: `unregistered ledgerId '${parsed.data.ledgerId}'`, + registered: Object.values(GUARD_LEDGER_IDS), + }; + } + + if (!deps.guardRejectionLog) { + reply.status(503); + return { error: 'guard_rejection_log_unavailable', message: 'GuardRejectionEventLog requires Redis' }; + } + + // +1: the window is half-open [since, until) — without it, a rejection + // emitted in the SAME millisecond as the query (the "I just got rejected, + // what is this pot" flow) would be invisible. + const until = parsed.data.untilMs ?? Date.now() + 1; + const since = parsed.data.sinceMs ?? until - SEVEN_DAYS_MS; + // sol R2 P1-1 (owner scope) + P2 (no fail-open masquerade): the query is + // HARD-scoped to the caller's ownerUserId — no principal can read another + // owner's thread/cat/invocation data. Strict variant + explicit 503: + // "ledger unavailable" must never look like "the pot never fired" + // (same discipline as V1 unmeasurable-vs-dormant). + try { + const { events, truncated } = await deps.guardRejectionLog.queryWindowStrictComplete({ + since, + until, + ledgerId: parsed.data.ledgerId, + ownerUserId: principal.userId, + }); + // sol P2-3: stats error → explicit `{ available: false }`, not fake zero. + // Query events are already fetched; a stats-only SCARD failure must not + // 503 the whole response (events are still valid). + let stats: Record; + try { + const anomalyRefCount = deps.ledgerStats + ? await deps.ledgerStats.anomalyReferenceCount(principal.userId, parsed.data.ledgerId) + : 0; + stats = { + anomalyRefCount, + howCounted: + 'scard guard-ledger:stats:{ownerUserId}:{ledgerId}:anomaly-refs — distinct deviation eventIds whose note references this pot', + }; + } catch { + stats = { available: false, reason: 'scard_error' }; + } + + return { + ledgerId: parsed.data.ledgerId, + window: { sinceMs: since, untilMs: until }, + events, + truncated, + stats, + }; + } catch (err) { + request.log.warn({ err, ledgerId: parsed.data.ledgerId }, 'F257 guard-rejection query failed (infra)'); + reply.status(503); + return { error: 'guard_rejection_query_failed', message: 'ledger unavailable — not a zero-events result' }; + } + }); +} diff --git a/packages/api/src/routes/callback-hold-ball-cancel-routes.ts b/packages/api/src/routes/callback-hold-ball-cancel-routes.ts index bd0b3c3198..fed1815ee1 100644 --- a/packages/api/src/routes/callback-hold-ball-cancel-routes.ts +++ b/packages/api/src/routes/callback-hold-ball-cancel-routes.ts @@ -91,6 +91,7 @@ export function registerHoldBallCancelRoutes(app: FastifyInstance, deps: HoldBal try { const cancelMessage = `🏓 ${catId} 持球已取消`; const stored = await messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, // sol R3 P1-1 userId: 'system', catId: null, content: cancelMessage, diff --git a/packages/api/src/routes/callback-hold-ball-routes.ts b/packages/api/src/routes/callback-hold-ball-routes.ts index 94d746b5dd..c9190800ae 100644 --- a/packages/api/src/routes/callback-hold-ball-routes.ts +++ b/packages/api/src/routes/callback-hold-ball-routes.ts @@ -21,6 +21,7 @@ import type { IMessageStore } from '../domains/cats/services/stores/ports/Messag import { extractHoldBallClaims } from '../infrastructure/grounding/claim-extractors.js'; import { checkGrounding } from '../infrastructure/grounding/grounding-checker.js'; import { groundingSampleStore } from '../infrastructure/grounding/grounding-sample-singleton.js'; +import { ledgerIdForGuard } from '../infrastructure/harness-eval/guard-ledger-registry.js'; import { createModuleLogger } from '../infrastructure/logger.js'; import { KILL_GRACE_MS, ManagedRunner } from '../infrastructure/managed-runner.js'; import type { DynamicTaskStore } from '../infrastructure/scheduler/DynamicTaskStore.js'; @@ -227,9 +228,11 @@ export interface HoldBallRouteDeps { message: string, messageId: string, contentBlocks?: undefined, - policy?: { sourceCategory?: string }, + policy?: { sourceCategory?: string; completionRequirement?: 'action-or-routing-exit' }, ): void | Promise; }; + /** F257 Phase A (Line B): Guard rejection event log — fail-open observation layer */ + guardRejectionLog?: import('../infrastructure/harness-eval/GuardRejectionEventLog.js').GuardRejectionEventLog; } /** @@ -322,6 +325,7 @@ function launchWakeWhenRunner(opts: { let messageId: string | undefined; try { const stored = await messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, // sol R3 P1-1 userId: 'scheduler', catId: null, content: triggerContent, @@ -350,6 +354,7 @@ function launchWakeWhenRunner(opts: { void Promise.resolve( deps.invokeTrigger.trigger(threadId, catId, userId, triggerContent, messageId, undefined, { sourceCategory: 'scheduled', + completionRequirement: 'action-or-routing-exit', }), ).catch(() => {}); } catch { @@ -420,8 +425,36 @@ export function registerCallbackHoldBallRoutes(app: FastifyInstance, deps: HoldB const parsed = holdBallSchema.safeParse(request.body); if (!parsed.success) { + // F257 V2: an ungrounded-timer reject (wakeAfterMs without waitSourceRef, + // the PR-O3 structural pot) is a pot firing — emit http_schema_reject. + // Other schema violations are plain input errors, not harness pots. + const ungroundedTimer = rawBody?.wakeAfterMs != null && rawBody?.waitSourceRef == null; + if (ungroundedTimer && deps.guardRejectionLog) { + const { randomUUID } = await import('node:crypto'); + deps.guardRejectionLog + .append({ + eventId: randomUUID(), + ledgerId: ledgerIdForGuard('hold_ball_wait_source_ref'), + kind: 'http_schema_reject', + threadId: actor.threadId, + catId: actor.catId as string, + guardId: 'hold_ball_wait_source_ref', + ownerUserId: actor.userId, + invocationId: record.invocationId ?? 'unknown', + sourceTool: 'hold_ball', + normalizedReason: 'missing_wait_source_ref', + layer: 'api-route', + timestamp: Date.now(), + correlationConfidence: record.invocationId ? 'exact' : 'window', + }) + .catch(() => {}); + } reply.status(400); - return { error: 'Invalid request body', details: parsed.error.issues }; + return { + error: 'Invalid request body', + details: parsed.error.issues, + ...(ungroundedTimer ? { ledgerId: ledgerIdForGuard('hold_ball_wait_source_ref') } : {}), + }; } const { reason, nextStep, wakeWhen } = parsed.data; @@ -472,8 +505,29 @@ export function registerCallbackHoldBallRoutes(app: FastifyInstance, deps: HoldB policyContext: { wakeAfterMs, hasEventCallback, hasWaitSourceRef: !!parsed.data.waitSourceRef }, }); if (guardResult.outcome === 'blocked' && guardResult.blockedResponse) { + // F257 V2: gate-keeping policy block is a pot firing — http_policy_reject. + if (deps.guardRejectionLog) { + const { randomUUID } = await import('node:crypto'); + deps.guardRejectionLog + .append({ + eventId: randomUUID(), + ledgerId: ledgerIdForGuard('gate_keeping_thread_default'), + kind: 'http_policy_reject', + threadId: actor.threadId, + catId: catIdStr, + guardId: 'gate_keeping_thread_default', + ownerUserId: userId, + invocationId: record.invocationId ?? 'unknown', + sourceTool: 'hold_ball', + normalizedReason: 'gate_keeping_thread_default_blocked', + layer: 'api-route', + timestamp: Date.now(), + correlationConfidence: record.invocationId ? 'exact' : 'window', + }) + .catch(() => {}); + } reply.status(400); - return guardResult.blockedResponse; + return { ...guardResult.blockedResponse, ledgerId: ledgerIdForGuard('gate_keeping_thread_default') }; } const currentCount = getHoldCount(threadId, catIdStr); @@ -483,10 +537,38 @@ export function registerCallbackHoldBallRoutes(app: FastifyInstance, deps: HoldB 'F167 C1: hold_ball rejected — maxHoldsPerWindow reached', ); reply.status(429); + // F257: emit http_rate_limit event (fail-open, fire-and-forget) + const rateLimitLedgerId = ledgerIdForGuard('hold_ball_rate_limit'); + if (deps.guardRejectionLog) { + const { randomUUID } = await import('node:crypto'); + deps.guardRejectionLog + .append({ + eventId: randomUUID(), + ledgerId: rateLimitLedgerId, + kind: 'http_rate_limit', + threadId, + catId: catIdStr, + guardId: 'hold_ball_rate_limit', + ownerUserId: userId, + invocationId: record.invocationId ?? 'unknown', + sourceTool: 'hold_ball', + normalizedReason: 'rate_limited', + layer: 'api-route', + timestamp: Date.now(), + correlationConfidence: record.invocationId ? 'exact' : 'window', + currentCount, + maxAllowed: MAX_HOLDS_PER_WINDOW, + windowMs: HOLD_WINDOW_MS, + }) + .catch(() => {}); + } return { error: `maxHoldsPerWindow (${MAX_HOLDS_PER_WINDOW} per ~1h window) reached. ` + 'You MUST pass the ball now: @ another cat or @co-creator.', + // F257 in-context observability: which pot rejected you — quote this + // ledgerId when filing an anomaly report (report_harness_signal). + ledgerId: rateLimitLedgerId, holdsInWindow: currentCount, maxHoldsPerWindow: MAX_HOLDS_PER_WINDOW, windowMs: HOLD_WINDOW_MS, @@ -570,6 +652,7 @@ export function registerCallbackHoldBallRoutes(app: FastifyInstance, deps: HoldB enabled: true, createdBy: `hold-ball:${catIdStr}`, createdAt: new Date().toISOString(), + retryAttempts: 0, }); // Atomic swap: try register; on failure, remove the just-inserted row so // prior hold stays authoritative (caller gets 500; prior wake still fires). @@ -651,6 +734,7 @@ export function registerCallbackHoldBallRoutes(app: FastifyInstance, deps: HoldB const holdSource = { ...HOLD_BALL_SOURCE, meta: { taskId, threadId, catId: catIdStr } }; try { const stored = await messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, // sol R3 P1-1 userId: 'system', catId: null, content: holdMessage, diff --git a/packages/api/src/routes/callback-multi-mention-routes.ts b/packages/api/src/routes/callback-multi-mention-routes.ts index d1976c0ebe..bc27c233e5 100644 --- a/packages/api/src/routes/callback-multi-mention-routes.ts +++ b/packages/api/src/routes/callback-multi-mention-routes.ts @@ -418,6 +418,7 @@ async function flushResult( // Post aggregated result to thread (with source for persistence) const stored = await messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, // sol R3 P1-1 userId, catId: result.request.callbackTo, content, diff --git a/packages/api/src/routes/callback-propose-profile-update-routes.ts b/packages/api/src/routes/callback-propose-profile-update-routes.ts index f1672f3ce1..53fb452251 100644 --- a/packages/api/src/routes/callback-propose-profile-update-routes.ts +++ b/packages/api/src/routes/callback-propose-profile-update-routes.ts @@ -144,6 +144,7 @@ export function registerCallbackProposeProfileUpdateRoutes(app: FastifyInstance, let stored: StoredMessage; try { stored = await messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, // sol R3 P1-1 userId: record.userId, catId: record.catId, content: `提议更新 ${record.catId} 的关系档案(primer)`, diff --git a/packages/api/src/routes/callback-propose-session-handoff-routes.ts b/packages/api/src/routes/callback-propose-session-handoff-routes.ts index f6feab436e..7ac6670b10 100644 --- a/packages/api/src/routes/callback-propose-session-handoff-routes.ts +++ b/packages/api/src/routes/callback-propose-session-handoff-routes.ts @@ -166,6 +166,7 @@ async function persistAndBroadcastCard( let stored: Awaited>; try { stored = await messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, // sol R3 P1-1 userId: record.userId, catId: record.catId, content: '提议 session 接力(封印当前 → 续接 fresh 自己)', diff --git a/packages/api/src/routes/callback-propose-thread-routes.ts b/packages/api/src/routes/callback-propose-thread-routes.ts index 4cc2b51d15..e86f32cf70 100644 --- a/packages/api/src/routes/callback-propose-thread-routes.ts +++ b/packages/api/src/routes/callback-propose-thread-routes.ts @@ -251,6 +251,7 @@ export function registerCallbackProposeThreadRoutes(app: FastifyInstance, deps: let stored; try { stored = await messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, // sol R3 P1-1 userId: record.userId, catId: record.catId, content: `提议新建 thread:${title}`, diff --git a/packages/api/src/routes/callbacks.ts b/packages/api/src/routes/callbacks.ts index 3f9aad6778..82437283de 100644 --- a/packages/api/src/routes/callbacks.ts +++ b/packages/api/src/routes/callbacks.ts @@ -24,6 +24,7 @@ import { getRichBlockBuffer } from '../domains/cats/services/agents/invocation/R import { stampVisibleTurn } from '../domains/cats/services/agents/invocation/visible-turn.js'; import { extractImagePaths, extractImageUrls } from '../domains/cats/services/agents/providers/image-paths.js'; import { analyzeA2AMentions } from '../domains/cats/services/agents/routing/a2a-mentions.js'; +import { pickSignatureLint, signatureLintExtra } from '../domains/cats/services/agents/routing/cat-signature-lint.js'; import { resolveCatTarget } from '../domains/cats/services/agents/routing/cat-target-resolver.js'; import { extractRichFromText } from '../domains/cats/services/agents/routing/rich-block-extract.js'; import { buildVoteNotification } from '../domains/cats/services/agents/routing/vote-intercept.js'; @@ -49,6 +50,7 @@ import { hydrateReplyPreview, type IMessageStore, isDelivered, + routedProvenance, type StoredMessage, } from '../domains/cats/services/stores/ports/MessageStore.js'; import { type ITaskStore, isSubjectOwnershipConflictError } from '../domains/cats/services/stores/ports/TaskStore.js'; @@ -66,6 +68,8 @@ import { buildThreadDeepLink } from '../infrastructure/connectors/connector-comm import { extractIssueTrackingClaims, extractPrTrackingClaims } from '../infrastructure/grounding/claim-extractors.js'; import { checkGrounding } from '../infrastructure/grounding/grounding-checker.js'; import { groundingSampleStore } from '../infrastructure/grounding/grounding-sample-singleton.js'; +import { registerReportHarnessSignalRoute } from '../infrastructure/harness-eval/deviation/report-harness-signal.js'; +import { GuardLedgerStats } from '../infrastructure/harness-eval/guard-ledger-registry.js'; import { createModuleLogger } from '../infrastructure/logger.js'; import type { SocketManager } from '../infrastructure/websocket/index.js'; import { scoreKeywordRelevance, tokenizeKeyword } from '../utils/keyword-relevance.js'; @@ -86,6 +90,7 @@ import { recordCallbackAuthFailure } from './callback-auth-telemetry.js'; import { registerCallbackBootcampRoutes } from './callback-bootcamp-routes.js'; import { registerCallbackDocumentRoutes } from './callback-document-routes.js'; import { registerCallbackGameRoutes } from './callback-game-routes.js'; +import { registerCallbackGuardRejectionRoutes } from './callback-guard-rejection-routes.js'; import { registerCallbackGuideRoutes } from './callback-guide-routes.js'; import { type HoldBallRouteDeps, registerCallbackHoldBallRoutes } from './callback-hold-ball-routes.js'; import { registerCallbackLarkActionRoutes } from './callback-lark-action-routes.js'; @@ -245,6 +250,11 @@ function buildPostMessageRoutingMessage( parts.push(`@${w.catId} 已停用,已跳过${alts ? `(可用替代:${alts})` : ''}。`); } else if (w.kind === 'target_not_in_thread') { parts.push(`@${w.catId} 不在目标 thread (${w.threadId}) 的参与者列表中,请确认 threadId 是否正确。`); + } else if (w.kind === 'mention_ambiguous') { + // F257 #1 (sol F7): ambiguity must never read as "not found" — offer the + // holders' explicit handles so the sender can retry deterministically. + const options = w.candidates.map((c) => `${c.mention}(${c.displayName})`).join('、'); + parts.push(`${w.mention} 同时匹配多只猫,未路由。请改用显式 handle:${options}。`); } else { parts.push(`${w.mention} 不存在,已跳过。`); } @@ -252,6 +262,56 @@ function buildPostMessageRoutingMessage( return parts.length > 0 ? parts.join(' ') : '消息已存储。'; } +/** + * F257 增补(operator 22:17 痛点 / kickoff 活体证据):routing mismatch gate。 + * 声明 targetCats 时,content 行首 @ 解析出的目标必须是声明集合的子集—— + * content 拉进声明外的猫 = 意图外副作用,HELD(freshness gate 同形态)而非 + * 静默仲裁(旧 content-wins 逻辑曾静默丢弃声明目标只路由 content 解析猫)。 + * 未声明 targetCats 的纯 content 路由不经此 gate(无声明即无 mismatch)。 + */ +/** + * sol R2 P1-2: gate 启用条件改用 raw 声明(rawDeclaredTargets),不用已解析集合。 + * 声明全部 unknown/disabled 时 resolvedDeclaredTargets 为空 → + * contentTargets 全量 unexpected → HELD(响应携带无效声明的 warning)。 + */ +function checkRoutingMismatch( + rawDeclaredTargets: readonly string[] | undefined, + resolvedDeclaredTargets: readonly CatId[], + contentTargets: readonly CatId[], +): + | { held: false } + | { + held: true; + response: { + status: 'held'; + reason: 'routing_mismatch'; + declaredTargets: string[]; + parsedTargets: string[]; + unexpectedTargets: string[]; + actions: string[]; + guidance: string; + }; + } { + if (!rawDeclaredTargets || rawDeclaredTargets.length === 0) return { held: false }; + const resolvedSet = new Set(resolvedDeclaredTargets.map(String)); + const unexpected = contentTargets.filter((id) => !resolvedSet.has(String(id))); + if (unexpected.length === 0) return { held: false }; + return { + held: true, + response: { + status: 'held', + reason: 'routing_mismatch', + declaredTargets: [...rawDeclaredTargets], + parsedTargets: contentTargets.map(String), + unexpectedTargets: unexpected.map(String), + actions: ['revise_content', 'expand_target_cats'], + guidance: + `content 行首 @ 解析出的目标(${unexpected.map((id) => `@${id}`).join('、')})不在声明的 targetCats 内。` + + '消息未发送。请修改 content 的 @ 写法,或把这些猫加进 targetCats 后重试。', + }, + }; +} + function buildRoutingOutcome(requestedIds: string[], enqueuedIds: readonly string[], enqueueAttempted: boolean) { if (!enqueueAttempted) { return { routed: [], notEnqueued: requestedIds }; @@ -488,6 +548,8 @@ export interface CallbackRoutesOptions { /** F211 Phase B: external IDE-direct runtime session registration. */ sessionChainStore?: import('../domains/cats/services/stores/ports/SessionChainStore.js').ISessionChainStore; runtimeSessionStore?: IRuntimeSessionStore; + /** F257 V1: deviation ledger for cat_cafe_report_harness_signal (T-C §3.6) */ + deviationEventLog?: import('../infrastructure/harness-eval/deviation/DeviationEventLog.js').IDeviationEventLog; eventAuditLog?: Pick; /** F128: cat-side thread proposals (propose endpoint) */ proposalStore?: import('../domains/cats/services/stores/ports/ProposalStore.js').IProposalStore; @@ -837,6 +899,15 @@ export const callbacksRoutes: FastifyPluginAsync = async ...(opts.eventAuditLog ? { eventAuditLog: opts.eventAuditLog } : {}), }); } + // F257 V1: cat_cafe_report_harness_signal (T-C §3.6) — deviationEventLog absent + // (no Redis) degrades inside the route to explicit 503, so register unconditionally. + // F257 V2 AC-B2: ledgerStats — anomaly reports referencing a pot ledgerId + // increment that pot's stats at write time (idempotent SADD, fail-open). + registerReportHarnessSignalRoute(app, { + messageStore, + ...(opts.deviationEventLog ? { deviationLog: opts.deviationEventLog } : {}), + ...(opts.redis ? { ledgerStats: new GuardLedgerStats(opts.redis) } : {}), + }); app.post('/api/callbacks/post-message', async (request, reply) => { const principal = requireCallbackPrincipal(request, reply); @@ -856,6 +927,36 @@ export const callbacksRoutes: FastifyPluginAsync = async const effectiveThreadId = threadResult.threadId; const { content, replyTo, clientMessageId, targetCats: explicitTargetCats } = parsed.data; + // sol R3 P1-2: pure routing plan BEFORE any side effects (TTS / claim). + // TTS calls the provider and writes audio cache — must not fire on HELD requests. + // Pure step 1: extract rich blocks from content text + const { cleanText: storedContent, blocks: extractedBlocks } = extractRichFromText(content); + + // Pure step 2: parse mentions + resolve targets + const senderCatId = createCatId(principal.catId); + // F182 AC-C1: use analyzeA2AMentions (captures routing_warnings for disabled cats) + const contentAnalysis = analyzeA2AMentions(storedContent, senderCatId); + const contentTargets = contentAnalysis.mentions; + const validExplicitTargets: CatId[] = []; + const routing_warnings: CatRoutingError[] = [...contentAnalysis.routing_warnings]; + for (const id of explicitTargetCats ?? []) { + const resolved = resolveCatTarget(id); + if ('ok' in resolved) { + validExplicitTargets.push(createCatId(resolved.ok)); + } else { + routing_warnings.push(resolved.error); + } + } + + // Pure step 3: mismatch gate (cheapest static check — sol R2 P1-1: must fire + // BEFORE claim so a HELD response doesn't consume the idempotency key) + // sol R2 P1-2: use raw explicitTargetCats for gate activation (all-invalid = still gated) + const mismatch = checkRoutingMismatch(explicitTargetCats, validExplicitTargets, contentTargets); + if (mismatch.held) { + return { ...mismatch.response, ...(clientMessageId ? { clientMessageId } : {}) }; + } + + // Consuming side effect: idempotency claim (safe now — gate already passed) if (clientMessageId && agentKeyRegistry) { const isFirst = await agentKeyRegistry.claimClientMessageId(principal.agentKeyId, clientMessageId); if (!isFirst) { @@ -863,7 +964,9 @@ export const callbacksRoutes: FastifyPluginAsync = async } } - const { cleanText: storedContent, blocks: extractedBlocks } = extractRichFromText(content); + // sol R3 P1-2: TTS synthesis AFTER gate + claim — HELD requests must not trigger + // the TTS provider. Concurrent same-clientMessageId requests now hit claim first + // (at-most-once TTS per unique message, restoring idempotent side-effect boundary). let richBlocks = extractedBlocks; const synthesizer = getVoiceBlockSynthesizer(); if (synthesizer && richBlocks.some((b) => b.kind === 'audio' && 'text' in b)) { @@ -874,20 +977,6 @@ export const callbacksRoutes: FastifyPluginAsync = async } } - const senderCatId = createCatId(principal.catId); - // F182 AC-C1: use analyzeA2AMentions (captures routing_warnings for disabled cats) - const contentAnalysis = analyzeA2AMentions(storedContent, senderCatId); - const contentTargets = contentAnalysis.mentions; - const validExplicitTargets: CatId[] = []; - const routing_warnings: CatRoutingError[] = [...contentAnalysis.routing_warnings]; - for (const id of explicitTargetCats ?? []) { - const resolved = resolveCatTarget(id); - if ('ok' in resolved) { - validExplicitTargets.push(createCatId(resolved.ok)); - } else { - routing_warnings.push(resolved.error); - } - } const mergedTargets = new Set([...contentTargets, ...validExplicitTargets]); // F177-H: Agent-key participant awareness — same check as invocation-auth @@ -936,7 +1025,14 @@ export const callbacksRoutes: FastifyPluginAsync = async const targetCatsExtra = validExplicitTargets.length ? { targetCats: validExplicitTargets } : {}; // #814: Mark as explicit post_message so frontend TD112 dedup does not // merge this into the cat's CLI stream bubble. - const extraParts = { isExplicitPost: true as const, ...richExtra, ...targetCatsExtra }; + // F257 #4: O2→O1 signature lint — observe-only structured signal recorded on + // text-bearing agent messages (non-blocking; never rejects a persisted message). + const extraParts = { + isExplicitPost: true as const, + ...richExtra, + ...targetCatsExtra, + ...signatureLintExtra(storedContent), + }; const extra = Object.keys(extraParts).length > 0 ? extraParts : undefined; const hasA2AMentions = !!(mentions.length > 0 && router && invocationRecordStore && effectiveThreadId); @@ -1004,6 +1100,8 @@ export const callbacksRoutes: FastifyPluginAsync = async extra: { isExplicitPost: true, ...(validExplicitTargets.length ? { targetCats: validExplicitTargets } : {}), + // F257 #4 (sol R4 P2): duplicate-recovery broadcast must also forward the verdict. + ...pickSignatureLint(duplicateMsg.extra), }, ...(duplicateMsg.mentionsUser ? { mentionsUser: true } : {}), ...(validatedReplyTo ? { replyTo: validatedReplyTo } : {}), @@ -1049,6 +1147,7 @@ export const callbacksRoutes: FastifyPluginAsync = async ...(mentionsUser ? { mentionsUser } : {}), origin: 'callback', timestamp: now, + ...routedProvenance('cat', contentAnalysis.attemptBatch), // F257 (T-A §3.4 / §4.5.1; sol R3 P1-1) ...(extra ? { extra } : {}), ...(validatedReplyTo ? { replyTo: validatedReplyTo } : {}), ...(willEnqueueToQueue ? { deliveryStatus: 'queued' as const } : {}), @@ -1104,6 +1203,8 @@ export const callbacksRoutes: FastifyPluginAsync = async extra: { isExplicitPost: true, ...(validExplicitTargets.length ? { targetCats: validExplicitTargets } : {}), + // F257 #4 (sol R1 P2-1): forward persisted signature lint to live delivery. + ...pickSignatureLint(storedMsg.extra), }, ...(mentionsUser ? { mentionsUser } : {}), ...(validatedReplyTo ? { replyTo: validatedReplyTo } : {}), @@ -1278,8 +1379,51 @@ export const callbacksRoutes: FastifyPluginAsync = async }; } + // ── Pure routing plan (shared by ALL execution branches) ── + // sol R3 P1: converge to a single routing plan before any side-effect, + // branch-specific intercept (assign_work), or freshness gate. This ensures + // the mismatch check fires consistently regardless of effectClass or freshness + // state — the root cause of R2→R3 consecutive branch misses. + + // Pure step 1: extract rich blocks from content text + // #83: Extract cc_rich blocks from post_message content (Route B for callback path) + const { cleanText: storedContent, blocks: extractedBlocks } = extractRichFromText(content); + + // Pure step 2: parse mentions + resolve targets + // Parse line-start @mentions (A2A rule: only line-start, strip code blocks, single target) + // F52: Cross-thread posts skip self-reference filter so @codex can trigger target thread's codex + const senderCatId = createCatId(actor.catId); + const contentAnalysis = analyzeA2AMentions(storedContent, isCrossThread ? undefined : senderCatId); + const contentTargets = contentAnalysis.mentions; + // F098-C1: Merge explicit targetCats with content-parsed mentions (deduped) + // F182: use resolveCatTarget to distinguish disabled vs unknown — collect routing_warnings + const validExplicitTargets: CatId[] = []; + const routing_warnings: CatRoutingError[] = [...contentAnalysis.routing_warnings]; + for (const id of explicitTargetCats ?? []) { + const resolved = resolveCatTarget(id); + if ('ok' in resolved) { + validExplicitTargets.push(createCatId(resolved.ok)); + } else { + routing_warnings.push(resolved.error); + app.log.warn( + { droppedId: id, catId: actor.catId, invocationId, reason: resolved.error.kind }, + '[callbacks/post-message] Dropped unavailable catId from targetCats', + ); + } + } + + // Pure step 3: mismatch gate — must fire BEFORE assign_work, freshness, claim, + // buffer consume, TTS. All branches share this single gate. + // sol R2 P1-2: use raw explicitTargetCats for gate activation (all-invalid = still gated) + const invocationPathMismatch = checkRoutingMismatch(explicitTargetCats, validExplicitTargets, contentTargets); + if (invocationPathMismatch.held) { + return { ...invocationPathMismatch.response, ...(clientMessageId ? { clientMessageId } : {}) }; + } + // F246 Phase B: assign_work effect-class intercept — hold as DispatchProposal // instead of auto-delivering. Only applies to cross-thread posts. + // Uses pre-computed routing plan (contentTargets + validExplicitTargets) — + // no duplicate analysis, and mismatch gate has already fired. if (isCrossThread && effectClass === 'assign_work' && opts.dispatchProposalStore) { // Idempotency: check if this clientMessageId already created a proposal if (clientMessageId) { @@ -1296,50 +1440,25 @@ export const callbacksRoutes: FastifyPluginAsync = async const proposalId = `dp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const ownerUserId = record.userId ?? 'default-user'; - // R3 fix: The intercept exits before the normal flow's analyzeA2AMentions (line 1294), - // so content @mentions would be lost. Parse them here and merge with explicit targetCats, - // mirroring the normal flow's merge at line 1312. Without this, assign_work routed via - // line-start @cat (no explicit targetCats) stores [] → nobody wakes on approval. - const interceptContentAnalysis = analyzeA2AMentions(content, undefined); // cross-thread: no self-filter - const interceptContentTargets = interceptContentAnalysis.mentions; - // R3 P1 fix (reviewer-confirmed): Validate targets via resolveCatTarget before - // persisting, mirroring the normal flow (line 1312). The approval replay path trusts - // proposal.targetCats as pre-resolved CatId[] and feeds them straight into - // enqueueA2ATargets without re-running resolveCatTarget. A typo or disabled cat - // would get persisted, approved, and silently fail to wake the intended cat. - const rawMergedTargets = [ - ...new Set([...interceptContentTargets, ...(explicitTargetCats ?? [])].map((t) => t.replace(/^@/, ''))), - ]; - const validInterceptTargets: string[] = []; - const interceptRoutingWarnings: CatRoutingError[] = []; - for (const id of rawMergedTargets) { - const resolved = resolveCatTarget(id); - if ('ok' in resolved) { - validInterceptTargets.push(resolved.ok); - } else { - interceptRoutingWarnings.push(resolved.error); - app.log.warn( - { droppedId: id, catId: actor.catId, reason: resolved.error.kind }, - '[F246/dispatch-proposal] Dropped unavailable catId from assign_work targetCats', - ); - } - } + // sol R3 P1-1: use pre-computed routing plan instead of duplicate analysis. + // contentTargets + validExplicitTargets are already resolved by the shared + // routing plan above; mismatch gate has already fired. The approval replay + // path trusts proposal.targetCats as pre-resolved CatId[] — no re-resolution. + const mergedTargetCats = [...new Set([...contentTargets, ...validExplicitTargets].map(String))]; // Fail-closed: if ALL targets are invalid, return routing failure — don't create - // a proposal that can never wake any cat (mirrors normal flow line 1672-1687). - if (rawMergedTargets.length > 0 && validInterceptTargets.length === 0) { + // a proposal that can never wake any cat. + if (mergedTargetCats.length === 0 && ((explicitTargetCats?.length ?? 0) > 0 || contentTargets.length > 0)) { return { isError: true, routed: [], - routing_warnings: interceptRoutingWarnings, - message: `assign_work dispatch failed: all target cats are unavailable (${rawMergedTargets.join(', ')})`, + routing_warnings, + message: `assign_work dispatch failed: all target cats are unavailable`, threadId: effectiveThreadId, ...(clientMessageId ? { clientMessageId } : {}), }; } - const mergedTargetCats = validInterceptTargets; - const proposal = await opts.dispatchProposalStore.create({ proposalId, sourceThreadId: actor.threadId, @@ -1382,10 +1501,10 @@ export const callbacksRoutes: FastifyPluginAsync = async // in the target thread. Uses independent seenCursor (NOT deliveryCursor — AC-A9). // Gate is fail-open: no cursor → forward; error → forward (log + continue). // - // Placement: AFTER all validation checks that can reject the request — - // resolveScopedThreadId (403), cross_post_no_routing (400), assign_work (400). - // The gate must not run before these or it would return 'held' instead of - // the correct error contract (gpt52 R1-P2 + R2-P2). + // Placement: AFTER all validation checks AND routing mismatch gate — + // resolveScopedThreadId (403), cross_post_no_routing (400), assign_work (400), + // routing_mismatch (HELD). sol R3 P1-2: freshness writes OTel counters and + // EventLog — these telemetry side effects must not fire for mismatched requests. if (deliveryCursorStore) { try { // Build visibility filter aligned with thread-context's canIncludeContextItem. @@ -1475,6 +1594,8 @@ export const callbacksRoutes: FastifyPluginAsync = async } } + // ── Consuming side effects (safe now — mismatch gate + assign_work + freshness have passed) ── + // At-least-once de-duplication: retries with same clientMessageId are treated as duplicate. if (clientMessageId) { const isFirstSeen = await registry.claimClientMessageId(invocationId, clientMessageId); @@ -1483,9 +1604,6 @@ export const callbacksRoutes: FastifyPluginAsync = async } } - // #83: Extract cc_rich blocks from post_message content (Route B for callback path) - const { cleanText: storedContent, blocks: extractedBlocks } = extractRichFromText(content); - // F088-J hotfix: Consume any buffered rich blocks (e.g. file blocks from generate_document). // CLI agents don't go through route-serial, so the buffer must be consumed here. // For route-serial agents, the buffer is already consumed before post_message — this is a no-op. @@ -1502,30 +1620,6 @@ export const callbacksRoutes: FastifyPluginAsync = async } } - // F52: isCrossThread already computed above (before idempotency claim, F193 AC-A4 gate). - - // Parse line-start @mentions (A2A rule: only line-start, strip code blocks, single target) - // Uses analyzeA2AMentions to capture routing_warnings for disabled cats (F182 KD-10). - // F52: Cross-thread posts skip self-reference filter so @codex can trigger target thread's codex - const senderCatId = createCatId(actor.catId); - const contentAnalysis = analyzeA2AMentions(storedContent, isCrossThread ? undefined : senderCatId); - const contentTargets = contentAnalysis.mentions; - // F098-C1: Merge explicit targetCats with content-parsed mentions (deduped) - // F182: use resolveCatTarget to distinguish disabled vs unknown — collect routing_warnings - const validExplicitTargets: CatId[] = []; - const routing_warnings: CatRoutingError[] = [...contentAnalysis.routing_warnings]; - for (const id of explicitTargetCats ?? []) { - const resolved = resolveCatTarget(id); - if ('ok' in resolved) { - validExplicitTargets.push(createCatId(resolved.ok)); - } else { - routing_warnings.push(resolved.error); - app.log.warn( - { droppedId: id, catId: actor.catId, invocationId, reason: resolved.error.kind }, - '[callbacks/post-message] Dropped unavailable catId from targetCats', - ); - } - } const mergedTargets = new Set([...contentTargets, ...validExplicitTargets]); // F177-H: Cross-post participant awareness — warn when target cats are not @@ -1605,7 +1699,15 @@ export const callbacksRoutes: FastifyPluginAsync = async const targetCatsExtra = validExplicitTargets.length ? { targetCats: validExplicitTargets } : {}; // #814: Mark as explicit post_message so frontend TD112 dedup does not // merge this into the cat's CLI stream bubble. - const extraParts = { isExplicitPost: true as const, ...richExtra, ...crossPostExtra, ...targetCatsExtra }; + // F257 #4: O2→O1 signature lint — observe-only structured signal recorded on + // text-bearing agent messages (non-blocking; never rejects a persisted message). + const extraParts = { + isExplicitPost: true as const, + ...richExtra, + ...crossPostExtra, + ...targetCatsExtra, + ...signatureLintExtra(storedContent), + }; const extra = Object.keys(extraParts).length > 0 ? extraParts : undefined; // F121: Validate replyTo — must exist in the same thread @@ -1735,6 +1837,8 @@ export const callbacksRoutes: FastifyPluginAsync = async ? { crossPost: { sourceThreadId: actor.threadId, sourceInvocationId: effectiveInvId } } : {}), ...(validExplicitTargets.length ? { targetCats: validExplicitTargets } : {}), + // F257 #4 (sol R4 P2): duplicate-recovery broadcast must also forward the verdict. + ...pickSignatureLint(duplicateMsg.extra), }, ...(duplicateMsg.mentionsUser ? { mentionsUser: true } : {}), ...(validatedReplyTo ? { replyTo: validatedReplyTo } : {}), @@ -1779,6 +1883,7 @@ export const callbacksRoutes: FastifyPluginAsync = async timestamp: now, threadId: effectiveThreadId, extra: persistedExtra, + ...routedProvenance('cat', contentAnalysis.attemptBatch), // F257 (T-A §3.4 / §4.5.1; sol R3 P1-1) ...(validatedReplyTo ? { replyTo: validatedReplyTo } : {}), ...(willEnqueueToQueue ? { deliveryStatus: 'queued' as const } : {}), }); @@ -1842,6 +1947,8 @@ export const callbacksRoutes: FastifyPluginAsync = async ? { crossPost: { sourceThreadId: actor.threadId, sourceInvocationId: effectiveInvId } } : {}), ...(validExplicitTargets.length ? { targetCats: validExplicitTargets } : {}), + // F257 #4 (sol R1 P2-1): forward persisted signature lint to live delivery. + ...pickSignatureLint(storedMsg.extra), }, ...(mentionsUser ? { mentionsUser } : {}), ...(validatedReplyTo ? { replyTo: validatedReplyTo } : {}), @@ -3531,6 +3638,7 @@ export const callbacksRoutes: FastifyPluginAsync = async let notificationMsg: Awaited> | undefined; try { notificationMsg = await messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, // sol R3 P1-1 userId: record.userId, catId: record.catId, content: notificationContent, @@ -3616,6 +3724,14 @@ export const callbacksRoutes: FastifyPluginAsync = async if (opts.holdBallDeps) { registerCallbackHoldBallRoutes(app, opts.holdBallDeps); + // F257 V2: MCP client-layer guard rejection ingest + ledgerId query + // surface (AC-B1 dual entry). Reuses the hold-ball deps' guardRejectionLog + // — same log instance the API-route emit points append to (single ledger). + registerCallbackGuardRejectionRoutes(app, { + guardRejectionLog: opts.holdBallDeps.guardRejectionLog, + ...(opts.redis ? { ledgerStats: new GuardLedgerStats(opts.redis) } : {}), + ...(opts.threadStore ? { threadStore: opts.threadStore } : {}), + }); } // Thread cats discovery for MCP diff --git a/packages/api/src/routes/eval-hub.ts b/packages/api/src/routes/eval-hub.ts index a2fdaff9d4..d77257c054 100644 --- a/packages/api/src/routes/eval-hub.ts +++ b/packages/api/src/routes/eval-hub.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto'; import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'; import type { Redis } from 'ioredis'; import { getRoster } from '../config/cat-config-loader.js'; @@ -8,6 +9,8 @@ import { import type { IMessageStore } from '../domains/cats/services/stores/ports/MessageStore.js'; import type { IThreadStore } from '../domains/cats/services/stores/ports/ThreadStore.js'; import { getEvalCatOverride, setEvalCatOverride } from '../infrastructure/harness-eval/domain/eval-domain-override.js'; +import type { GuardRejectionEventLog } from '../infrastructure/harness-eval/GuardRejectionEventLog.js'; +import { ledgerIdForGuard } from '../infrastructure/harness-eval/guard-ledger-registry.js'; import { loadDomains, loadEvalHubSummary } from '../infrastructure/harness-eval/hub/eval-hub-read-model.js'; import { ensureEvalDomainThreads } from '../infrastructure/harness-eval/hub/eval-hub-thread-ensure.js'; import { @@ -16,7 +19,7 @@ import { type InvokeTriggerProvider, } from '../infrastructure/harness-eval/manual-trigger/index.js'; import { - type GitPublisher, + type ArtifactPublisher, handlePublishVerdict, type VerdictGenerator, } from '../infrastructure/harness-eval/publish-verdict/publish-verdict.js'; @@ -25,7 +28,6 @@ import { registerCallbackAuthHook, requireCallbackPrincipal } from './callback-a export type { GenerateNowInput, - GenerateNowSuccess, HandlerError, InvokeTriggerLike, InvokeTriggerOutcome, @@ -50,8 +52,17 @@ export interface EvalHubRoutesOptions { invokeTriggerProvider?: InvokeTriggerProvider; /** F192 OQ-21: message store for delivering invocation packet on manual trigger. */ messageStore?: IMessageStore; - /** F192 Phase H: GitPublisher impl (real = git worktree + gh; tests inject mock). */ - gitPublisher?: GitPublisher; + /** + * F257 / F192 sunset: durable artifact publisher for verdict bundles. + * Replaces the deprecated Git worktree publisher. + */ + artifactPublisher?: ArtifactPublisher; + /** + * F257 / F192 sunset: durable artifact store root where ArtifactPublisher + * commits verdicts. Passed to Eval Hub read-model so the summary can surface + * artifact-store verdicts alongside legacy in-repo verdicts. + */ + artifactStoreRoot?: string; /** * F192 Phase H: domain → verdict generator map. Real impl (e.g. * `generateA2aLiveVerdict` for eval:a2a) wired here; tests inject mock. @@ -73,6 +84,12 @@ export interface EvalHubRoutesOptions { * publish-verdict — same gap as F178/F223 (post_message, workspace_navigate). */ agentKeyRegistry?: AgentKeyAuthRegistry; + /** KD-17: GuardRejectionEventLog for eval:harness-ledger snapshot-first manual trigger. */ + guardRejectionLog?: GuardRejectionEventLog; + /** F257: InjectionTraceStore for per-segment judgment engine (manual trigger path). */ + traceStore?: import('../domains/prompt-hooks/InjectionTraceStore.js').InjectionTraceStore; + /** F257 Phase D: SegmentJudgmentCache for persisting latest judgments for lifeline API. */ + judgmentCache?: import('../domains/prompt-hooks/SegmentJudgmentCache.js').SegmentJudgmentCache; } function requireSession(request: FastifyRequest, reply: FastifyReply): string | null { @@ -99,7 +116,10 @@ export const evalHubRoutes: FastifyPluginAsync = async (ap if (!userId) return; try { - const summary = loadEvalHubSummary({ harnessFeedbackRoot: opts.harnessFeedbackRoot }); + const summary = loadEvalHubSummary({ + harnessFeedbackRoot: opts.harnessFeedbackRoot, + artifactStoreRoot: opts.artifactStoreRoot, + }); // OQ-20: Apply Redis evalCat overrides to domain summaries if (opts.redis) { @@ -225,6 +245,12 @@ export const evalHubRoutes: FastifyPluginAsync = async (ap // cloud R5 P2 (PR-2): pass wired publish-verdict domain set so // buildEvalCatInvocation omits publish instructions for unwired domains. wiredPublishDomains: new Set(Object.keys(opts.verdictGenerators ?? {})), + // KD-17: pass guardRejectionLog for eval:harness-ledger snapshot-first. + guardRejectionLog: opts.guardRejectionLog, + // F257: pass traceStore for per-segment judgment engine. + traceStore: opts.traceStore, + // F257 Phase D: pass judgmentCache for persisting latest judgments. + judgmentCache: opts.judgmentCache, }, { domainId, userId }, ); @@ -235,45 +261,14 @@ export const evalHubRoutes: FastifyPluginAsync = async (ap return result; }); - // F192 OQ-21: Manual generate-now (eval:a2a only in v1; others return 501). - // Handler in manual-trigger/generate-now.ts. + // F192/F257 sunset: legacy generate-now is retained only as a fail-closed + // compatibility endpoint. It never writes runtime evidence into the checkout. app.post('/api/eval-domains/:domainId/generate-now', async (request, reply) => { const userId = requireSession(request, reply); if (!userId) return; - // Cloud codex R8 P1 (network): single-user mode (no DEFAULT_OWNER_USER_ID) - // makes requireConnectorWriteOwner() a no-op, and /api/session mints - // default-user for any client. Without this guard, an exposed non-loopback - // instance would let any remote client dirty docs/harness-feedback/. - // Match push.ts / config-secrets.ts ordering: network guard first. - const networkError = requireConnectorWriteNetworkGuard(request); - if (networkError) { - return reply.status(networkError.status).send({ error: networkError.error }); - } - - // Cloud codex R7 P1: this endpoint writes verdict + bundle files under - // docs/harness-feedback/. GET /api/session mints a `default-user` session - // without proving ownership — same as other repo-mutating surfaces - // (push.ts, config-secrets.ts, connector-hub.ts), require owner privilege - // before dirtying the working tree. - const ownerError = requireConnectorWriteOwner(userId); - if (ownerError) { - return reply.status(ownerError.status).send({ error: ownerError.error }); - } - const { domainId } = request.params as { domainId: string }; - // Cloud codex R4 P2: body is user-supplied JSON; validate field types at - // route layer (defense in depth — handler also re-validates so direct test - // calls remain protected). const body = (request.body ?? {}) as Record; - for (const field of ['verdictId', 'snapshotName', 'attributionName'] as const) { - const v = body[field]; - if (v !== undefined && typeof v !== 'string') { - return reply.status(400).send({ - error: `${field} must be a string if provided (got ${typeof v})`, - }); - } - } const result = await handleGenerateNow( { harnessFeedbackRoot: opts.harnessFeedbackRoot }, @@ -296,7 +291,7 @@ export const evalHubRoutes: FastifyPluginAsync = async (ap // 砚砚 R4 P1 + cloud R4 P1: route uses CALLBACK auth (invocationId + callbackToken), // NOT browser session — MCP tools don't send session cookies. catId is derived // from the server-trusted callback principal, NOT body (which is spoofable). - // Generator + GitPublisher injected at bootstrap (real impls), tests pass mocks. + // F257 / F192 sunset: generator + ArtifactPublisher injected at bootstrap; tests pass mocks. app.post('/api/eval-domains/:domainId/publish-verdict', async (request, reply) => { // 砚砚 R4 P1 #1 + R9 P1: requireCallbackPrincipal (NOT requireSession). // Accept both invocation principals (per-call MCP) AND agent_key principals @@ -321,7 +316,7 @@ export const evalHubRoutes: FastifyPluginAsync = async (ap const result = await handlePublishVerdict( { harnessFeedbackRoot: opts.harnessFeedbackRoot, - gitPublisher: opts.gitPublisher, + artifactPublisher: opts.artifactPublisher, generator, // 砚砚 R6 P1: pass redis so handler reads OQ-20 override (same instance // as handleTriggerNow uses — symmetric wake/publish for override cats). @@ -342,6 +337,34 @@ export const evalHubRoutes: FastifyPluginAsync = async (ap ); if ('error' in result) { + // F257 V2: a 403 from the publish handler is the domain-authority pot + // firing (the audit's flagship real interception — blocked cross-domain + // publish). Emit publish_policy_reject (fail-open) and carry the pot + // coordinate in the response. The principal-kind 403 above is an auth + // shape error, not a behavioral pot — deliberately not emitted. + if (result.status === 403 && opts.guardRejectionLog) { + const publishLedgerId = ledgerIdForGuard('publish_verdict_authority'); + opts.guardRejectionLog + .append({ + eventId: randomUUID(), + ledgerId: publishLedgerId, + kind: 'publish_policy_reject', + threadId: principal.kind === 'invocation' ? principal.threadId : 'unknown', + catId: principal.catId as string, + guardId: 'publish_verdict_authority', + ownerUserId: principal.userId, + invocationId: principal.kind === 'invocation' ? principal.invocationId : 'unknown', + sourceTool: 'publish_verdict', + normalizedReason: String(result.error ?? 'publish_forbidden'), + layer: 'api-route', + timestamp: Date.now(), + correlationConfidence: principal.kind === 'invocation' ? 'exact' : 'window', + }) + .catch(() => {}); + return reply + .status(result.status) + .send({ error: result.error, detail: result.detail, ledgerId: publishLedgerId }); + } return reply.status(result.status).send({ error: result.error, detail: result.detail }); } return result; diff --git a/packages/api/src/routes/messages.ts b/packages/api/src/routes/messages.ts index 9a3d25f338..0f381a4477 100644 --- a/packages/api/src/routes/messages.ts +++ b/packages/api/src/routes/messages.ts @@ -14,7 +14,7 @@ */ import { randomUUID } from 'node:crypto'; -import { type CatId, type CatRoutingError, catRegistry, type MessageContent } from '@cat-cafe/shared'; +import { type CatId, type CatRoutingError, catRegistry, createCatId, type MessageContent } from '@cat-cafe/shared'; import type { SessionStore } from '@cat-cafe/shared/utils'; import multipart from '@fastify/multipart'; import type { FastifyPluginAsync } from 'fastify'; @@ -60,7 +60,7 @@ import type { IDraftStore } from '../domains/cats/services/stores/ports/DraftSto import type { IGameStore } from '../domains/cats/services/stores/ports/GameStore.js'; import type { IInvocationRecordStore } from '../domains/cats/services/stores/ports/InvocationRecordStore.js'; import type { IMessageStore } from '../domains/cats/services/stores/ports/MessageStore.js'; -import { isDelivered } from '../domains/cats/services/stores/ports/MessageStore.js'; +import { isDelivered, routedProvenance } from '../domains/cats/services/stores/ports/MessageStore.js'; import type { ISummaryStore } from '../domains/cats/services/stores/ports/SummaryStore.js'; import type { IThreadStore } from '../domains/cats/services/stores/ports/ThreadStore.js'; import { isInternalNonQuotableParent, isSystemUserMessage } from '../domains/cats/services/stores/visibility.js'; @@ -253,6 +253,10 @@ function formatRoutingWarnings(warnings: CatRoutingError[]): string { parts.push(`@${w.catId} 已停用,已跳过${alts ? `(可用替代:${alts})` : ''}。`); } else if (w.kind === 'target_not_in_thread') { parts.push(`@${w.catId} 不在目标 thread (${w.threadId}) 的参与者列表中,请确认 threadId 是否正确。`); + } else if (w.kind === 'mention_ambiguous') { + // F257 #1 (dev-628ea4d1): refused to guess between multiple holders + const options = w.candidates.map((c) => `${c.mention}(${c.displayName})`).join('、'); + parts.push(`${w.mention} 同时匹配多只猫,未路由。请改用显式 handle:${options}。`); } else { parts.push(`${w.mention} 不存在,已跳过。`); } @@ -292,6 +296,7 @@ async function persistA2ARoutingMessage( if (!msg.content) return undefined; try { const stored = await messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, // sol R3 P1-1 userId: 'system', catId: null, content: msg.content, @@ -558,6 +563,7 @@ export const messagesRoutes: FastifyPluginAsync = async ( // Store user message in the game thread const userMessage = await opts.messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, // sol R3 P1-1 userId, catId: null, content, @@ -619,6 +625,7 @@ export const messagesRoutes: FastifyPluginAsync = async ( intent, hasMentions, routing_warnings, + attemptBatch, } = await router.resolveTargetsAndIntent(content, resolvedThreadId, { persist: true, }); @@ -629,6 +636,24 @@ export const messagesRoutes: FastifyPluginAsync = async ( ? [...new Set(whisperRecipients)] : [...resolvedTargetCats]; if (targetCats.length === 0) { + // F257 #1 (sol F3): ambiguous-only message resolves to zero targets by design. + // Return the structured refusal WITH the disambiguation guidance — the generic + // "no cats available" copy would misreport a config-collision as an empty roster. + const ambiguous = (routing_warnings ?? []).filter((w) => w.kind === 'mention_ambiguous'); + if (ambiguous.length > 0) { + const guidance = formatRoutingWarnings(ambiguous); + opts.socketManager.broadcastAgentMessage( + { + type: 'system_info', + catId: createCatId('unknown'), + content: JSON.stringify({ type: 'warning', message: guidance }), + timestamp: Date.now(), + }, + resolvedThreadId, + ); + reply.status(400); + return { error: guidance, code: 'MENTION_AMBIGUOUS', routing_warnings: ambiguous }; + } reply.status(400); return { error: '没有可用的猫猫成员,请先在设置中添加一只猫猫', code: 'NO_TARGETS' }; } @@ -738,6 +763,7 @@ export const messagesRoutes: FastifyPluginAsync = async ( threadId: resolvedThreadId, idempotencyKey: resolvedIdempotencyKey, deliveryStatus: 'queued', // F117: not visible in history/context/mentions until delivered + ...routedProvenance('user', attemptBatch), // F257 (T-A §3.4 / §4.5.1; sol R3 P1-1) ...(contentBlocks ? { contentBlocks } : {}), ...(whisperVisibility && whisperRecipients ? { visibility: whisperVisibility, whisperTo: whisperRecipients } @@ -877,6 +903,7 @@ export const messagesRoutes: FastifyPluginAsync = async ( threadId: resolvedThreadId, idempotencyKey: resolvedIdempotencyKey, deliveryStatus: 'queued', + ...routedProvenance('user', attemptBatch), // F257 (T-A §3.4 / §4.5.1; sol R3 P1-1) ...(contentBlocks ? { contentBlocks } : {}), ...(whisperVisibility && whisperRecipients ? { visibility: whisperVisibility, whisperTo: whisperRecipients } @@ -980,6 +1007,7 @@ export const messagesRoutes: FastifyPluginAsync = async ( mentions: targetCats, timestamp: Date.now(), threadId: resolvedThreadId, + ...routedProvenance('user', attemptBatch), // F257 (T-A §3.4 / §4.5.1; sol R3 P1-1) ...(contentBlocks ? { contentBlocks } : {}), ...(whisperVisibility && whisperRecipients ? { visibility: whisperVisibility, whisperTo: whisperRecipients } @@ -1842,7 +1870,8 @@ export const messagesRoutes: FastifyPluginAsync = async ( m.extra?.targetCats || m.extra?.scheduler || m.extra?.systemKind || - m.extra?.a2aRouting + m.extra?.a2aRouting || + m.extra?.signatureLint ? { extra: { ...(m.extra.rich ? { rich: m.extra.rich } : {}), @@ -1853,6 +1882,8 @@ export const messagesRoutes: FastifyPluginAsync = async ( ...(m.extra.scheduler ? { scheduler: m.extra.scheduler } : {}), ...(m.extra.systemKind ? { systemKind: m.extra.systemKind } : {}), ...(m.extra.a2aRouting ? { a2aRouting: m.extra.a2aRouting } : {}), + // F257 #4 (sol R1 P2-1): expose signature lint to the message read model. + ...(m.extra.signatureLint ? { signatureLint: m.extra.signatureLint } : {}), }, } : {}), diff --git a/packages/api/src/routes/prompt-injection-hooks.ts b/packages/api/src/routes/prompt-injection-hooks.ts index bd3061e9eb..4195282807 100644 --- a/packages/api/src/routes/prompt-injection-hooks.ts +++ b/packages/api/src/routes/prompt-injection-hooks.ts @@ -12,6 +12,7 @@ import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import type { HookManifest, HookVariableDef } from '@cat-cafe/shared'; import { HookRegistry } from '../domains/prompt-hooks/HookRegistry.js'; function findProjectRoot(): string { @@ -30,7 +31,9 @@ export interface HookContentResult { hasBackup: false; content: string; baseContent: string; + templateRef: string; vars: string[]; + variableDefs: HookVariableDef[]; } // Lazy-init registry singleton (same instance as manifest route) @@ -53,7 +56,7 @@ export async function resolveHookContent(id: string): Promise { + let enabled = true; + let hasOverride = false; + let hasContentOverride = false; + let hasVersionSnapshot = false; + const availableEpochVersions: number[] = []; + + if (overrideStore) { + const override = await overrideStore.getOverride(segment.id); + if (override) { + enabled = override.enabled !== false; + hasOverride = true; + hasContentOverride = typeof override.contentOverride === 'string' && override.contentOverride.length > 0; + } + if (typeof overrideStore.listVersions === 'function') { + const versions = await overrideStore.listVersions(segment.id); + if (versions.length > 0) { + hasVersionSnapshot = true; + for (const v of versions) availableEpochVersions.push(v.version); + } + } + } + + let hasLocalOverlay = false; + let hasBackup = false; + const overlayPath = getTemplateOverlayPath(segment.id); + if (overlayPath) { + hasLocalOverlay = existsSync(overlayPath); + hasBackup = existsSync(`${overlayPath}.bak`); + } + + return resolveSegmentEnablementMatrix({ + segmentId: segment.id, + safetyTier: segment.safetyTier, + allowLocalOverride: segment.allowLocalOverride, + disableable: segment.disableable, + localOverlay: { hasOverlay: hasLocalOverlay, hasBackup }, + runtimeOverride: { + enabled, + hasOverride, + hasContentOverride, + hasVersionSnapshot, + availableEpochVersions, + }, + }); } function toManifestSegment(hook: HookManifest): ManifestSegment { @@ -101,6 +173,7 @@ function toManifestSegment(hook: HookManifest): ManifestSegment { disableable: hook.disableable, consumer: info.consumer, relatedFeature: null, + enablementMatrix: undefined as unknown as SegmentEnablementMatrix, }; } @@ -113,8 +186,28 @@ function toManifestSegment(hook: HookManifest): ManifestSegment { * Tier 2 (N2, M1, M2): observe-only trace adapters — no resolver, no versioning. * External (H1, H2, H3): Claude Code shell hooks — separate injection system. */ +function supplementalSegmentDefaults(id: string): Omit { + return { + name: '', + category: '', + lifecycleStage: '', + source: '', + sourceType: '', + trigger: '', + purpose: '', + userExplanation: '', + priority: '', + transparencyTier: 'visible-by-default', + governanceTier: 'immutable', + consumer: '', + relatedFeature: null, + enablementMatrix: undefined as unknown as SegmentEnablementMatrix, + }; +} + const SUPPLEMENTAL_SEGMENTS: ManifestSegment[] = [ { + ...supplementalSegmentDefaults('N2'), id: 'N2', name: '对话历史增量', category: 'navigation', @@ -126,14 +219,12 @@ const SUPPLEMENTAL_SEGMENTS: ManifestSegment[] = [ userExplanation: '其他猫在你上次发言后说了什么(增量对话历史)', priority: 'per-turn:observe', safetyTier: 'readonly', - transparencyTier: 'visible-by-default', - governanceTier: 'immutable', allowLocalOverride: false, disableable: false, consumer: 'route-assembler', - relatedFeature: null, }, { + ...supplementalSegmentDefaults('M1'), id: 'M1', name: 'Dispatch 任务上下文', category: 'transport', @@ -145,14 +236,13 @@ const SUPPLEMENTAL_SEGMENTS: ManifestSegment[] = [ userExplanation: '外部项目 dispatch 时注入的任务上下文(missionPrefix)', priority: 'per-turn:transport', safetyTier: 'readonly', - transparencyTier: 'visible-by-default', - governanceTier: 'immutable', allowLocalOverride: false, disableable: false, consumer: 'invocation-layer', relatedFeature: 'F070', }, { + ...supplementalSegmentDefaults('M2'), id: 'M2', name: 'Transcript 路径提示', category: 'transport', @@ -164,14 +254,12 @@ const SUPPLEMENTAL_SEGMENTS: ManifestSegment[] = [ userExplanation: '会话 transcript 路径信息(始终附加)', priority: 'per-turn:transport', safetyTier: 'readonly', - transparencyTier: 'debug-only', - governanceTier: 'immutable', allowLocalOverride: false, disableable: false, consumer: 'invocation-layer', - relatedFeature: null, }, { + ...supplementalSegmentDefaults('H1'), id: 'H1', name: 'SessionStart Hook', category: 'external', @@ -183,14 +271,12 @@ const SUPPLEMENTAL_SEGMENTS: ManifestSegment[] = [ userExplanation: 'Claude Code 会话启动时运行的 shell hook', priority: 'session-init:external', safetyTier: 'readonly', - transparencyTier: 'opt-in-view', - governanceTier: 'human-gated', allowLocalOverride: false, disableable: false, consumer: 'claude-code', - relatedFeature: null, }, { + ...supplementalSegmentDefaults('H2'), id: 'H2', name: 'PostCompact Hook', category: 'external', @@ -202,14 +288,12 @@ const SUPPLEMENTAL_SEGMENTS: ManifestSegment[] = [ userExplanation: 'Claude Code 压缩上下文后运行的 shell hook', priority: 'session-init:external', safetyTier: 'readonly', - transparencyTier: 'opt-in-view', - governanceTier: 'human-gated', allowLocalOverride: false, disableable: false, consumer: 'claude-code', - relatedFeature: null, }, { + ...supplementalSegmentDefaults('H3'), id: 'H3', name: 'SessionStop Hook', category: 'external', @@ -221,12 +305,9 @@ const SUPPLEMENTAL_SEGMENTS: ManifestSegment[] = [ userExplanation: 'Claude Code 会话结束时运行的 shell hook(不进 model prompt)', priority: 'session-init:external', safetyTier: 'readonly', - transparencyTier: 'debug-only', - governanceTier: 'human-gated', allowLocalOverride: false, disableable: false, consumer: 'claude-code', - relatedFeature: null, }, ]; @@ -234,9 +315,9 @@ const SUPPLEMENTAL_SEGMENTS: ManifestSegment[] = [ // Registry singleton (lazy init, scan once per process) // --------------------------------------------------------------------------- -let cachedResult: { hookSegments: ManifestSegment[]; allSegments: ManifestSegment[] } | null = null; +let cachedResult: { root: string; hookSegments: ManifestSegment[]; allSegments: ManifestSegment[] } | null = null; -function getManifestSegments(): { hookSegments: ManifestSegment[]; allSegments: ManifestSegment[] } { +function getManifestSegments(): { root: string; hookSegments: ManifestSegment[]; allSegments: ManifestSegment[] } { if (cachedResult) return cachedResult; const root = findProjectRoot(); @@ -253,15 +334,38 @@ function getManifestSegments(): { hookSegments: ManifestSegment[]; allSegments: a.id.localeCompare(b.id, undefined, { numeric: true }), ); - cachedResult = { hookSegments, allSegments }; + cachedResult = { root, hookSegments, allSegments }; return cachedResult; } +async function attachEnablementMatrices( + segments: ManifestSegment[], + overrideStore: HookOverrideStore | undefined, +): Promise { + const matrices = await Promise.all( + segments.map((s) => + buildEnablementMatrix( + { + id: s.id, + safetyTier: s.safetyTier as SafetyTier, + allowLocalOverride: s.allowLocalOverride, + disableable: s.disableable, + }, + overrideStore, + ), + ), + ); + return segments.map((s, i) => ({ ...s, enablementMatrix: matrices[i] })); +} + // --------------------------------------------------------------------------- // Route plugin // --------------------------------------------------------------------------- -export const promptInjectionManifestRoutes: FastifyPluginAsync = async (app) => { +export const promptInjectionManifestRoutes: FastifyPluginAsync = async ( + app, + opts, +) => { app.get('/api/prompt-injection/manifest', async (request, reply) => { if (!resolveUserId(request)) { reply.status(401); @@ -270,9 +374,10 @@ export const promptInjectionManifestRoutes: FastifyPluginAsync = async (app) => try { const { hookSegments, allSegments } = getManifestSegments(); + const segments = await attachEnablementMatrices(allSegments, opts.overrideStore); return { schemaVersion: '2.0.0', - segments: allSegments, + segments, totalActive: hookSegments.length, totalObserveOnly: SUPPLEMENTAL_SEGMENTS.filter((s) => s.sourceType === 'observe-only').length, totalExternal: SUPPLEMENTAL_SEGMENTS.filter((s) => s.sourceType === 'shell-hook').length, diff --git a/packages/api/src/routes/prompt-injection-preview.ts b/packages/api/src/routes/prompt-injection-preview.ts index 9c7925e9da..0de92ec50b 100644 --- a/packages/api/src/routes/prompt-injection-preview.ts +++ b/packages/api/src/routes/prompt-injection-preview.ts @@ -22,6 +22,7 @@ import { } from '../domains/cats/services/context/SystemPromptBuilder.js'; import { getActivePackBlocks } from '../domains/packs/getActivePackBlocks.js'; import { PackStore } from '../domains/packs/PackStore.js'; +import { refreshOverrideSnapshot } from '../domains/prompt-hooks/PipelinePromptBuilder.js'; import { findMonorepoRoot } from '../utils/monorepo-root.js'; import { resolveUserId } from '../utils/request-identity.js'; @@ -50,6 +51,8 @@ export const promptInjectionPreviewRoutes: FastifyPluginAsync = async (app) => { const mcpAvailable = (catConfig?.mcpSupport ?? false) && !!mcpServerPath; const packBlocks = await getActivePackBlocks(packStore); + // F237 PR3: ensure overrides are loaded so preview reflects active overrides + await refreshOverrideSnapshot(); const compiled = buildStaticIdentity(catId as CatId, { mcpAvailable, packBlocks, annotateSegments: true }); if (!compiled) { reply.status(404); diff --git a/packages/api/src/routes/prompt-injection.ts b/packages/api/src/routes/prompt-injection.ts index 21c89cb1c2..7b4ce2e2fe 100644 --- a/packages/api/src/routes/prompt-injection.ts +++ b/packages/api/src/routes/prompt-injection.ts @@ -12,6 +12,8 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'; import { dirname } from 'node:path'; +import type { HookVariableDef, SafetyTier, SegmentEnablementMatrix } from '@cat-cafe/shared'; +import { resolveSegmentEnablementMatrix } from '@cat-cafe/shared'; import type { FastifyPluginAsync } from 'fastify'; import YAML from 'yaml'; import { @@ -28,8 +30,9 @@ import { stripComments, } from '../domains/cats/services/context/prompt-template-loader.js'; import { RICH_BLOCK_SHORT } from '../domains/cats/services/context/rich-block-rules.js'; +import type { HookOverrideStore } from '../domains/prompt-hooks/HookOverrideStore.js'; import { resolveUserId } from '../utils/request-identity.js'; -import { resolveHookContent } from './prompt-injection-hooks.js'; +import { getHookManifest, getHookVariableDefs, resolveHookContent } from './prompt-injection-hooks.js'; /** * Session-only auth for write operations — reads sessionUserId directly @@ -124,12 +127,167 @@ function invalidateNativeL0CacheForSegment(segmentId: string): void { } } +/** Extract {{NAME}} placeholders from a template source string. */ +function extractPlaceholders(content: string): string[] { + const vars: string[] = []; + for (const m of content.matchAll(/\{\{(\w+)\}\}/g)) { + if (!vars.includes(m[1])) vars.push(m[1]); + } + return vars; +} + +/** + * Reject content that has replaced runtime-expanded values back into the source. + * The saved source must retain every {{NAME}} placeholder present in the + * immutable base template. Using the current effective overlay as reference + * would let a legacy expanded overlay be re-saved without placeholders. + */ +function validateSourcePlaceholders(content: string, referenceContent: string): string | null { + const required = extractPlaceholders(referenceContent); + if (required.length === 0) return null; + const present = new Set(extractPlaceholders(content)); + const missing = required.filter((name) => !present.has(name)); + if (missing.length === 0) return null; + return `Missing required placeholders: ${missing.map((n) => `{{${n}}}`).join(', ')}`; +} + +type RouteError = { status: number; error: string }; +type OverlaySaveResult = { status: number; saved: true; path: string } | RouteError; +type OverlayRestoreResult = { status: number; restored: true } | RouteError; + +function isRouteError(result: unknown): result is RouteError { + return typeof result === 'object' && result !== null && 'error' in result; +} + +function renderPreview( + id: string, + content: string, + meta: SegmentMeta, +): { status: number; rendered: string } | RouteError { + if (typeof content !== 'string') { + return { status: 400, error: 'Missing content field' }; + } + + if (meta.ext === 'yaml') { + try { + const parsed: unknown = YAML.parse(content); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return { status: 400, error: 'YAML must be a mapping (object), not a scalar or list' }; + } + const entries: Record = {}; + for (const [k, v] of Object.entries(parsed)) { + entries[k] = typeof v === 'string' ? v.trimEnd() : String(v); + } + return { status: 200, rendered: JSON.stringify(entries, null, 2) }; + } catch (e) { + return { status: 400, error: `Invalid YAML: ${e instanceof Error ? e.message : String(e)}` }; + } + } + + const vars = resolveVars(id); + return { status: 200, rendered: renderTemplate(stripComments(content), vars) }; +} + +function saveOverlay(id: string, content: string, meta: SegmentMeta): OverlaySaveResult { + if (typeof content !== 'string' || content.trim().length === 0) { + return { status: 400, error: 'Missing or empty content field' }; + } + + // Validate YAML segments parse to a string-valued mapping + if (meta.ext === 'yaml') { + const yamlErr = validateYamlStringMapping(content); + if (yamlErr) { + return { status: 400, error: yamlErr }; + } + } + + // Reject runtime-expanded values being written back as source. + // Use the immutable base template as reference, not the current effective + // overlay — otherwise a legacy expanded overlay could be re-saved. + const baseContent = getTemplateRawContent(id, false) ?? ''; + const placeholderErr = validateSourcePlaceholders(content, baseContent); + if (placeholderErr) { + return { status: 400, error: placeholderErr }; + } + + const fileInfo = getTemplateFileInfo(id); + if (!fileInfo) { + return { status: 500, error: 'Template file info not found' }; + } + + const localPath = getTemplateOverlayPath(id); + if (!localPath) { + return { status: 500, error: 'Template overlay path not found' }; + } + mkdirSync(dirname(localPath), { recursive: true }); + + // Backup existing .local to .local.bak before overwriting + if (existsSync(localPath)) { + const bakPath = `${localPath}.bak`; + atomicCopyFileSync(localPath, bakPath); + } + + atomicWriteFileSync(localPath, content); + invalidateNativeL0CacheForSegment(id); + + return { status: 200, saved: true, path: fileInfo.local }; +} + +function restoreOverlay(id: string, meta: SegmentMeta): OverlayRestoreResult { + const fileInfo = getTemplateFileInfo(id); + if (!fileInfo?.local) { + return { status: 500, error: 'Template file info not found' }; + } + + const localPath = getTemplateOverlayPath(id); + if (!localPath) { + return { status: 500, error: 'Template overlay path not found' }; + } + + const bakPath = `${localPath}.bak`; + if (!existsSync(bakPath)) { + return { status: 404, error: 'No backup file exists' }; + } + + // Validate backup content before restoring (P2-7: same gate as save path) + const bakContent = readFileSync(bakPath, 'utf-8'); + if (meta.ext === 'yaml') { + const yamlErr = validateYamlStringMapping(bakContent); + if (yamlErr) { + return { status: 400, error: `Backup file is invalid — ${yamlErr}` }; + } + } + + // Reject backups that contain runtime-expanded values instead of placeholders. + const baseContent = getTemplateRawContent(id, false) ?? ''; + const placeholderErr = validateSourcePlaceholders(bakContent, baseContent); + if (placeholderErr) { + return { status: 400, error: `Backup file is invalid — ${placeholderErr}` }; + } + + atomicCopyFileSync(bakPath, localPath); + invalidateNativeL0CacheForSegment(id); + + return { status: 200, restored: true }; +} + +// ── Route options ──────────────────────────────────────────── + +export interface PromptInjectionRoutesOptions { + /** Runtime override store. When absent, matrix uses default override state. */ + overrideStore?: HookOverrideStore; +} + // ── Dynamic segment metadata (derived from TEMPLATE_FILES registry) ── interface SegmentMeta { allowLocalOverride: boolean; ext: 'yaml' | 'md'; + templateRef: string; vars: string[]; + variableDefs: HookVariableDef[]; + safetyTier: SafetyTier; + disableable: boolean; } /** Known runtime values for template variable preview rendering */ @@ -150,7 +308,22 @@ function resolveSegmentMeta(id: string): SegmentMeta | null { if (!vars.includes(m[1])) vars.push(m[1]); } } - return { allowLocalOverride: !!fileInfo.local, ext, vars }; + // Canonical variable definitions come from the hook manifest registry first, + // then fall back to the TEMPLATE_FILES registry for non-hook template-backed segments. + const variableDefs = getHookVariableDefs(id) ?? (fileInfo.variables || []); + // F257 Console 判据⑥: pull safety constraints from the hook manifest registry + // so the enablement matrix is authoritative. Use the on-demand registry rather + // than the lazy pipeline cache, which may be uninitialized at startup. + const manifest = getHookManifest(id); + return { + allowLocalOverride: !!fileInfo.local, + ext, + templateRef: fileInfo.base, + vars, + variableDefs, + safetyTier: manifest?.safetyTier ?? 'readonly', + disableable: manifest?.disableable ?? false, + }; } function resolveVars(segmentId: string): Record { @@ -163,9 +336,54 @@ function resolveVars(segmentId: string): Record { return result; } +async function buildContentEnablementMatrix( + segmentId: string, + meta: SegmentMeta, + hasLocalOverlay: boolean, + hasBackup: boolean, + overrideStore: HookOverrideStore | undefined, +): Promise { + let enabled = true; + let hasOverride = false; + let hasContentOverride = false; + let hasVersionSnapshot = false; + const availableEpochVersions: number[] = []; + + if (overrideStore) { + const override = await overrideStore.getOverride(segmentId); + if (override) { + enabled = override.enabled !== false; + hasOverride = true; + hasContentOverride = typeof override.contentOverride === 'string' && override.contentOverride.length > 0; + } + if (typeof overrideStore.listVersions === 'function') { + const versions = await overrideStore.listVersions(segmentId); + if (versions.length > 0) { + hasVersionSnapshot = true; + for (const v of versions) availableEpochVersions.push(v.version); + } + } + } + + return resolveSegmentEnablementMatrix({ + segmentId, + safetyTier: meta.safetyTier, + allowLocalOverride: meta.allowLocalOverride, + disableable: meta.disableable, + localOverlay: { hasOverlay: hasLocalOverlay, hasBackup }, + runtimeOverride: { + enabled, + hasOverride, + hasContentOverride, + hasVersionSnapshot, + availableEpochVersions, + }, + }); +} + // ── Route plugin ───────────────────────────────────────────── -export const promptInjectionRoutes: FastifyPluginAsync = async (app) => { +export const promptInjectionRoutes: FastifyPluginAsync = async (app, opts) => { /** * GET /api/prompt-injection/segment/:id/content * Returns raw template content (base or override) + override status. @@ -187,11 +405,20 @@ export const promptInjectionRoutes: FastifyPluginAsync = async (app) => { } const status = getOverrideStatus(id); + const hasLocalOverlay = status?.hasOverride ?? false; const content = getTemplateRawContent(id, true); - const baseContent = status?.hasOverride ? getTemplateRawContent(id, false) : content; + const baseContent = hasLocalOverlay ? getTemplateRawContent(id, false) : content; const overlayPath = getTemplateOverlayPath(id); const hasBackup = overlayPath ? existsSync(`${overlayPath}.bak`) : false; + const enablementMatrix = await buildContentEnablementMatrix( + id, + meta, + hasLocalOverlay, + hasBackup, + opts.overrideStore, + ); + return { segmentId: id, allowLocalOverride: meta.allowLocalOverride, @@ -199,7 +426,10 @@ export const promptInjectionRoutes: FastifyPluginAsync = async (app) => { hasBackup, content: content ?? '', baseContent: baseContent ?? '', + templateRef: meta.templateRef, vars: meta.vars, + variableDefs: meta.variableDefs, + enablementMatrix, }; }); @@ -223,34 +453,9 @@ export const promptInjectionRoutes: FastifyPluginAsync = async (app) => { } const { content } = request.body ?? {}; - if (typeof content !== 'string') { - reply.status(400); - return { error: 'Missing content field' }; - } - - const vars = resolveVars(id); - let rendered: string; - if (meta.ext === 'yaml') { - // YAML preview: parse and show per-key values - try { - const parsed: unknown = YAML.parse(content); - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - reply.status(400); - return { error: 'YAML must be a mapping (object), not a scalar or list' }; - } - const entries: Record = {}; - for (const [k, v] of Object.entries(parsed)) { - entries[k] = typeof v === 'string' ? v.trimEnd() : String(v); - } - rendered = JSON.stringify(entries, null, 2); - } catch (e) { - reply.status(400); - return { error: `Invalid YAML: ${e instanceof Error ? e.message : String(e)}` }; - } - } else { - rendered = renderTemplate(stripComments(content), vars); - } - return { segmentId: id, rendered }; + const preview = renderPreview(id, content, meta); + reply.status(preview.status); + return isRouteError(preview) ? { error: preview.error } : { segmentId: id, rendered: preview.rendered }; }, ); @@ -274,49 +479,15 @@ export const promptInjectionRoutes: FastifyPluginAsync = async (app) => { reply.status(404); return { error: `Segment ${id} is not template-backed` }; } - if (!meta.allowLocalOverride) { + if (!meta.allowLocalOverride || meta.safetyTier === 'readonly') { reply.status(403); return { error: `Segment ${id} is readonly — override not allowed` }; } const { content } = request.body ?? {}; - if (typeof content !== 'string' || content.trim().length === 0) { - reply.status(400); - return { error: 'Missing or empty content field' }; - } - - // Validate YAML segments parse to a string-valued mapping - if (meta.ext === 'yaml') { - const yamlErr = validateYamlStringMapping(content); - if (yamlErr) { - reply.status(400); - return { error: yamlErr }; - } - } - - const fileInfo = getTemplateFileInfo(id); - if (!fileInfo) { - reply.status(500); - return { error: 'Template file info not found' }; - } - - const localPath = getTemplateOverlayPath(id); - if (!localPath) { - reply.status(500); - return { error: 'Template overlay path not found' }; - } - mkdirSync(dirname(localPath), { recursive: true }); - - // Backup existing .local to .local.bak - if (existsSync(localPath)) { - const bakPath = `${localPath}.bak`; - atomicCopyFileSync(localPath, bakPath); - } - - atomicWriteFileSync(localPath, content); - invalidateNativeL0CacheForSegment(id); - - return { segmentId: id, saved: true, path: fileInfo.local }; + const result = saveOverlay(id, content, meta); + reply.status(result.status); + return isRouteError(result) ? { error: result.error } : { segmentId: id, saved: true, path: result.path }; }, ); @@ -374,38 +545,13 @@ export const promptInjectionRoutes: FastifyPluginAsync = async (app) => { reply.status(404); return { error: `Segment ${id} is not template-backed` }; } - if (!meta.allowLocalOverride) { + if (!meta.allowLocalOverride || meta.safetyTier === 'readonly') { reply.status(403); return { error: `Segment ${id} is readonly` }; } - const fileInfo = getTemplateFileInfo(id); - if (!fileInfo?.local) { - reply.status(500); - return { error: 'Template file info not found' }; - } - const localPath = getTemplateOverlayPath(id); - if (!localPath) { - reply.status(500); - return { error: 'Template overlay path not found' }; - } - const bakPath = `${localPath}.bak`; - if (!existsSync(bakPath)) { - reply.status(404); - return { error: 'No backup file exists' }; - } - - // Validate backup content before restoring (P2-7: same gate as save path) - if (meta.ext === 'yaml') { - const bakContent = readFileSync(bakPath, 'utf-8'); - const yamlErr = validateYamlStringMapping(bakContent); - if (yamlErr) { - reply.status(400); - return { error: `Backup file is invalid — ${yamlErr}` }; - } - } - atomicCopyFileSync(bakPath, localPath); - invalidateNativeL0CacheForSegment(id); - return { segmentId: id, restored: true }; + const result = restoreOverlay(id, meta); + reply.status(result.status); + return isRouteError(result) ? { error: result.error } : { segmentId: id, restored: true }; }); }; diff --git a/packages/api/src/routes/proposal-approve-dispatch.ts b/packages/api/src/routes/proposal-approve-dispatch.ts index e976bd617f..e678ec8f90 100644 --- a/packages/api/src/routes/proposal-approve-dispatch.ts +++ b/packages/api/src/routes/proposal-approve-dispatch.ts @@ -4,6 +4,7 @@ import type { QueueProcessor } from '../domains/cats/services/agents/invocation/ import { parseIntent } from '../domains/cats/services/context/IntentParser.js'; import type { AgentRouter } from '../domains/cats/services/index.js'; import type { IMessageStore } from '../domains/cats/services/stores/ports/MessageStore.js'; +import { routedProvenance } from '../domains/cats/services/stores/ports/MessageStore.js'; import { primaryMentionHandleForCatId } from '../utils/cat-mention-handle.js'; import { enrichWithParentThreadHeader } from './proposal-enrich-header.js'; @@ -119,6 +120,11 @@ export async function appendApprovedInitialMessage({ sourceCatHandle, ); const stored = await messageStore.append({ + provenance: { + author: sourceCatId ? ('cat' as const) : ('user' as const), + routed: false, + observation: 'original', + }, // sol R3 P1-1: no-router fallback — parser did not run userId, catId: sourceCatId ?? null, // AC-AA4: source cat is the message author content: enrichedFallback, @@ -219,6 +225,10 @@ export async function appendApprovedInitialMessage({ mentions: [], timestamp: Date.now(), threadId, + // F257 V1 (sol R1 P1-1): the batch that actually routed this dispatch. + // Span basis = the parser's scan text (raw initialMessage, T-A spanBasis) — + // stored content additionally carries the injected parent-thread header. + ...routedProvenance(sourceCatId ? 'cat' : 'user', resolved.attemptBatch), // F257 (T-A §3.4 / §4.5.1; sol R3 P1-1) extra: crossPostExtra, // AC-AA5 }); return { @@ -245,6 +255,7 @@ export async function appendApprovedInitialMessage({ mentions: [...targetCats], timestamp: Date.now(), threadId, + ...routedProvenance(sourceCatId ? 'cat' : 'user', resolved.attemptBatch), // F257 (T-A §3.4 / §4.5.1; sol R3 P1-1) extra: crossPostExtra, // AC-AA5 }); return { @@ -265,6 +276,7 @@ export async function appendApprovedInitialMessage({ threadId, idempotencyKey: `proposal-initial:${proposalId}`, deliveryStatus: 'queued', + ...routedProvenance(sourceCatId ? 'cat' : 'user', resolved.attemptBatch), // F257 (T-A §3.4 / §4.5.1; sol R3 P1-1) extra: crossPostExtra, // AC-AA5 }); storedMessageId = stored.id; diff --git a/packages/api/src/routes/schedule.ts b/packages/api/src/routes/schedule.ts index b9687dd2d7..9e362c5bfc 100644 --- a/packages/api/src/routes/schedule.ts +++ b/packages/api/src/routes/schedule.ts @@ -445,6 +445,7 @@ export const scheduleRoutes: FastifyPluginAsync = async ( enabled: true, createdBy: actor.createdBy, createdAt: new Date().toISOString(), + retryAttempts: 0, }; dynamicTaskStore.insert(def); diff --git a/packages/api/src/routes/thread-branch.ts b/packages/api/src/routes/thread-branch.ts index 848d61480a..4dcf917e9f 100644 --- a/packages/api/src/routes/thread-branch.ts +++ b/packages/api/src/routes/thread-branch.ts @@ -148,6 +148,10 @@ export const threadBranchRoutes: FastifyPluginAsync = return { error: '无法从已删除的消息创建分支', code: 'FROM_MESSAGE_DELETED' }; } const messagesToCopy = allMessages.slice(0, cutIndex + 1); + // An edit is a fresh authenticated operator observation. Capture one + // request-time coordinate before writes so it enters the window where the + // edit happened instead of inheriting the source row's historical score. + const editTimestamp = Date.now(); // ④ Create new thread with "(分支)" suffix const branchTitle = sourceThread.title ? `${sourceThread.title} (分支)` : '分支对话'; @@ -162,19 +166,36 @@ export const threadBranchRoutes: FastifyPluginAsync = for (let i = 0; i < messagesToCopy.length; i++) { const src = messagesToCopy[i]!; const isLast = i === messagesToCopy.length - 1; - const content = isLast && editedContent !== undefined ? editedContent : src.content; + const isEdited = isLast && editedContent !== undefined; + const content = isEdited ? editedContent : src.content; + + // sol R4 P1-2: COPY the trusted source declaration — never rebuild the + // author axis from nullable catId (a catId:null system notice/relay + // would masquerade as a user utterance and enter magic-word exact). + // routed stays false on the copy: no parser ran over this append and + // the source's routingFact (if any) belongs to the original message. + // A source with no verifiable declaration (legacy) is explicitly + // 'unknown' — it exits every exact cohort instead of being guessed. + const provenance = isEdited + ? { author: 'user' as const, routed: false, observation: 'original' as const } + : { + author: src.provenance?.author ?? ('unknown' as const), + routed: false, + observation: 'derived' as const, + sourceRef: `message:${src.id}`, + }; await messageStore.append({ - userId: src.userId, - catId: src.catId, + provenance, + userId: isEdited ? userId : src.userId, + catId: isEdited ? null : src.catId, content, - ...(src.contentBlocks && !(isLast && editedContent !== undefined) - ? { contentBlocks: src.contentBlocks } - : {}), + ...(src.contentBlocks && !isEdited ? { contentBlocks: src.contentBlocks } : {}), ...(src.metadata ? { metadata: src.metadata } : {}), ...(src.origin ? { origin: src.origin } : {}), + ...(src.source && !isEdited ? { source: src.source } : {}), mentions: [...src.mentions], - timestamp: src.timestamp, + timestamp: isEdited ? editTimestamp : src.timestamp, threadId: newThread.id, }); } diff --git a/packages/api/src/routes/votes.ts b/packages/api/src/routes/votes.ts index f23c34fb11..03e60fcd47 100644 --- a/packages/api/src/routes/votes.ts +++ b/packages/api/src/routes/votes.ts @@ -92,6 +92,7 @@ export async function closeVoteInternal( if (messageStore) { try { const stored = await messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, // sol R3 P1-1 userId: votingState.createdBy, catId: null, content: `投票结果: ${votingState.question}`, @@ -290,6 +291,7 @@ export const voteRoutes: FastifyPluginAsync = async (app, opt if (messageStore) { try { const stored = await messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, // sol R3 P1-1 userId: 'system', catId: null, content: `投票结果: ${votingState.question}`, @@ -399,6 +401,7 @@ export const voteRoutes: FastifyPluginAsync = async (app, opt if (messageStore) { try { const stored = await messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, // sol R3 P1-1 userId: result.createdBy, catId: null, content: `投票结果: ${result.question}`, diff --git a/packages/api/test/a2a-ack-liveness.test.js b/packages/api/test/a2a-ack-liveness.test.js new file mode 100644 index 0000000000..d3caa989c9 --- /dev/null +++ b/packages/api/test/a2a-ack-liveness.test.js @@ -0,0 +1,244 @@ +// @ts-check + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { buildVoidAckEvent } from '../dist/domains/ball-custody/ball-custody-events.js'; +import { + classifyDurableTriggerResult, + evaluateAckLiveness, +} from '../dist/domains/cats/services/agents/routing/a2a-ack-liveness.js'; + +/** + * LI-005 — A2A Ack Liveness Detection unit tests. + * + * Red-first TDD: each test targets a specific detection scenario from the + * LI-005 candidate definition (live-candidates-2026-07-14.md). + */ + +/** Helper: build default input with overrides. */ +function input(overrides = {}) { + return { + isA2AInvocation: true, + toolNames: [], + lineStartMentions: [], + structuredTargetCats: [], + hasCoCreatorLineStartMention: false, + ...overrides, + }; +} + +describe('evaluateAckLiveness', () => { + // ── Core detection: void ack ──────────────────────────────────────────── + + it('fires when A2A invocation ends without routing exit or durable trigger', () => { + const result = evaluateAckLiveness(input()); + assert.equal(result.shouldEmit, true, 'should fire on bare A2A ack'); + assert.equal(result.hasRoutingExit, false); + assert.equal(result.hasDurableTrigger, false); + }); + + // ── Suppression: non-A2A ──────────────────────────────────────────────── + + it('never fires for user-initiated invocations', () => { + const result = evaluateAckLiveness(input({ isA2AInvocation: false })); + assert.equal(result.shouldEmit, false, 'user-initiated should not fire'); + }); + + // ── Suppression: routing exits ────────────────────────────────────────── + + it('suppressed by line-start @mention (ball passed forward)', () => { + const result = evaluateAckLiveness(input({ lineStartMentions: ['codex'] })); + assert.equal(result.shouldEmit, false); + assert.equal(result.hasRoutingExit, true); + }); + + it('suppressed by structured targetCats (post_message routing)', () => { + const result = evaluateAckLiveness(input({ structuredTargetCats: ['opus'] })); + assert.equal(result.shouldEmit, false); + assert.equal(result.hasRoutingExit, true); + }); + + it('suppressed by @co-creator line-start mention', () => { + const result = evaluateAckLiveness(input({ hasCoCreatorLineStartMention: true })); + assert.equal(result.shouldEmit, false); + assert.equal(result.hasRoutingExit, true); + }); + + // ── Suppression: durable triggers ─────────────────────────────────────── + + it('suppressed by hold_ball tool call', () => { + const result = evaluateAckLiveness(input({ toolNames: ['mcp__cat-cafe-collab__cat_cafe_hold_ball'] })); + assert.equal(result.shouldEmit, false); + assert.equal(result.hasDurableTrigger, true); + }); + + it('create_task does NOT suppress (bookkeeping only, no wake mechanism)', () => { + const result = evaluateAckLiveness(input({ toolNames: ['cat_cafe_create_task'] })); + assert.equal(result.shouldEmit, true, 'create_task has no invokeTrigger'); + assert.equal(result.hasDurableTrigger, false); + }); + + it('suppressed by register_scheduled_task tool call', () => { + const result = evaluateAckLiveness( + input({ toolNames: ['mcp__cat-cafe-collab__cat_cafe_register_scheduled_task'] }), + ); + assert.equal(result.shouldEmit, false); + assert.equal(result.hasDurableTrigger, true); + }); + + it('suppressed by register_pr_tracking tool call', () => { + const result = evaluateAckLiveness(input({ toolNames: ['cat_cafe_register_pr_tracking'] })); + assert.equal(result.shouldEmit, false); + assert.equal(result.hasDurableTrigger, true); + }); + + it('suppressed by register_issue_tracking tool call', () => { + const result = evaluateAckLiveness(input({ toolNames: ['cat_cafe_register_issue_tracking'] })); + assert.equal(result.shouldEmit, false); + assert.equal(result.hasDurableTrigger, true); + }); + + it('suppressed by community_await_external tool call', () => { + const result = evaluateAckLiveness(input({ toolNames: ['cat_cafe_community_await_external'] })); + assert.equal(result.shouldEmit, false); + assert.equal(result.hasDurableTrigger, true); + }); + + // ── Non-trigger tools do NOT suppress ─────────────────────────────────── + + it('non-trigger tools (search_evidence, post_message, create_task) do not suppress', () => { + const result = evaluateAckLiveness( + input({ + toolNames: ['cat_cafe_search_evidence', 'cat_cafe_post_message', 'cat_cafe_create_task', 'Read', 'Bash'], + }), + ); + assert.equal(result.shouldEmit, true, 'informational/bookkeeping tools should not suppress'); + assert.equal(result.hasDurableTrigger, false); + }); + + // ── Combination: routing exit + no trigger still suppresses ───────────── + + it('routing exit alone suppresses even without durable trigger', () => { + const result = evaluateAckLiveness(input({ lineStartMentions: ['sol'] })); + assert.equal(result.shouldEmit, false); + assert.equal(result.hasRoutingExit, true); + assert.equal(result.hasDurableTrigger, false); + }); + + // ── Combination: trigger alone suppresses even without routing exit ───── + + it('durable trigger alone suppresses even without routing exit', () => { + const result = evaluateAckLiveness(input({ toolNames: ['cat_cafe_hold_ball'] })); + assert.equal(result.shouldEmit, false); + assert.equal(result.hasRoutingExit, false); + assert.equal(result.hasDurableTrigger, true); + }); +}); + +// ─── classifyDurableTriggerResult (Sol R3 P1 fix) ──────────────────────────── + +describe('classifyDurableTriggerResult', () => { + // ── Level 1: structural toolResultStatus ───────────────────────────────── + + it('returns true when toolResultStatus is ok (Codex/Gemini)', () => { + assert.equal(classifyDurableTriggerResult('cat_cafe_hold_ball', '{}', 'ok'), true); + }); + + it('returns false when toolResultStatus is error', () => { + assert.equal(classifyDurableTriggerResult('cat_cafe_hold_ball', '{}', 'error'), false); + }); + + // ── Level 2: tool-specific body parsing ────────────────────────────────── + + it('hold_ball: {status: "ok"} → confirmed', () => { + const body = JSON.stringify({ status: 'ok', held: true, taskId: 'hold-123' }); + assert.equal(classifyDurableTriggerResult('cat_cafe_hold_ball', body, undefined), true); + }); + + it('register_pr_tracking: {status: "ok"} → confirmed', () => { + const body = JSON.stringify({ status: 'ok', threadId: 't-1', task: {} }); + assert.equal(classifyDurableTriggerResult('cat_cafe_register_pr_tracking', body, undefined), true); + }); + + it('register_issue_tracking: {status: "ok"} → confirmed', () => { + const body = JSON.stringify({ status: 'ok', threadId: 't-1', task: {} }); + assert.equal(classifyDurableTriggerResult('cat_cafe_register_issue_tracking', body, undefined), true); + }); + + it('register_scheduled_task: {success: true} → confirmed (Sol R3 P1)', () => { + const body = JSON.stringify({ success: true, task: { id: 'dyn-123', label: 'test' } }); + assert.equal(classifyDurableTriggerResult('cat_cafe_register_scheduled_task', body, undefined), true); + }); + + it('community_await_external: {state: "awaiting_external"} → confirmed (Sol R3 P1)', () => { + const body = JSON.stringify({ subjectKey: 'sk-1', appended: true, state: 'awaiting_external' }); + assert.equal(classifyDurableTriggerResult('cat_cafe_community_await_external', body, undefined), true); + }); + + // ── MCP prefix variant ─────────────────────────────────────────────────── + + it('handles mcp__cat-cafe-collab__ prefix (suffix matching)', () => { + const body = JSON.stringify({ success: true, task: {} }); + assert.equal( + classifyDurableTriggerResult('mcp__cat-cafe-collab__cat_cafe_register_scheduled_task', body, undefined), + true, + ); + }); + + // ── Failure cases ──────────────────────────────────────────────────────── + + it('returns false for explicit error body', () => { + assert.equal(classifyDurableTriggerResult('cat_cafe_hold_ball', '{"isError":true}', undefined), false); + }); + + it('returns false for non-JSON content (fail-closed)', () => { + assert.equal(classifyDurableTriggerResult('cat_cafe_hold_ball', 'Rate limit exceeded', undefined), false); + }); + + it('returns false for unknown body shape (fail-closed)', () => { + assert.equal(classifyDurableTriggerResult('cat_cafe_hold_ball', '{"foo":"bar"}', undefined), false); + }); + + it('returns false for empty content', () => { + assert.equal(classifyDurableTriggerResult('cat_cafe_hold_ball', undefined, undefined), false); + }); + + // ── Non-durable-trigger tools are always false ─────────────────────────── + + it('returns false for non-durable-trigger tools even with ok status', () => { + assert.equal(classifyDurableTriggerResult('cat_cafe_post_message', '{"status":"ok"}', 'ok'), false); + assert.equal(classifyDurableTriggerResult('cat_cafe_create_task', '{"status":"ok"}', 'ok'), false); + }); +}); + +// ─── buildVoidAckEvent builder tests ────────────────────────────────────── + +describe('buildVoidAckEvent', () => { + it('builds well-formed ball.void_ack event without trigger ID', () => { + const event = buildVoidAckEvent({ threadId: 't-1', messageId: 'm-42', at: 1700000000000 }); + assert.equal(event.kind, 'ball.void_ack'); + assert.equal(event.classification, 'state-changing'); + assert.equal(event.subjectKey, 'ball:thread:t-1'); + assert.equal(event.sourceEventId, 'route:m-42:void_ack'); + assert.equal(event.at, 1700000000000); + assert.deepEqual(event.payload, {}); + }); + + it('includes a2aTriggerMessageId in payload when provided (provenance)', () => { + const event = buildVoidAckEvent({ + threadId: 't-1', + messageId: 'm-42', + a2aTriggerMessageId: 'trigger-msg-99', + at: 1700000000000, + }); + assert.equal(event.kind, 'ball.void_ack'); + assert.deepEqual(event.payload, { a2aTriggerMessageId: 'trigger-msg-99' }); + }); + + it('sourceEventId differs from void_pass for same messageId', () => { + const ack = buildVoidAckEvent({ threadId: 't-1', messageId: 'm-42', at: 1700000000000 }); + // void_pass uses `route:{messageId}:void`, ack uses `route:{messageId}:void_ack` + assert.ok(ack.sourceEventId.endsWith(':void_ack')); + assert.ok(!ack.sourceEventId.endsWith(':void_ack:void_ack'), 'no double suffix'); + }); +}); diff --git a/packages/api/test/a2a-routing-persist.test.js b/packages/api/test/a2a-routing-persist.test.js index 75d75129a2..0b6808d7dd 100644 --- a/packages/api/test/a2a-routing-persist.test.js +++ b/packages/api/test/a2a-routing-persist.test.js @@ -66,6 +66,7 @@ describe('A2A routing message persistence (#648)', () => { it('persists a2a_handoff as system message with correct shape', () => { const store = new MessageStore(); const result = store.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, userId: 'system', catId: null, content: '布偶猫 → 缅因猫', @@ -95,6 +96,7 @@ describe('A2A routing message persistence (#648)', () => { it('stored messageId can be attached to broadcast payload', () => { const store = new MessageStore(); const result = store.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, userId: 'system', catId: null, content: '布偶猫 → 缅因猫', @@ -143,6 +145,13 @@ function buildDeps(overrides = {}) { }, router: { resolveTargetsAndIntent: mock.fn(async () => ({ + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, targetCats: ['opus'], intent: { intent: 'execute' }, })), diff --git a/packages/api/test/agent-router-speech-mentions.test.js b/packages/api/test/agent-router-speech-mentions.test.js index 2f751ccb72..c94efe1a92 100644 --- a/packages/api/test/agent-router-speech-mentions.test.js +++ b/packages/api/test/agent-router-speech-mentions.test.js @@ -43,7 +43,8 @@ test('resolveTargetsAndIntent supports speech-style "at + nickname" mentions', a }), ); - const result = await router.resolveTargetsAndIntent('at咱的砚砚 和 at 宪宪 你们出来了', 'thread-voice'); + // P1-4: @宪宪/@砚砚 removed from breeds → use @缅因猫/@布偶猫 + const result = await router.resolveTargetsAndIntent('at咱的缅因猫 和 at 布偶猫 你们出来了', 'thread-voice'); assert.deepEqual(result.targetCats, ['codex', 'opus']); }); @@ -93,7 +94,8 @@ test('resolveTargetsAndIntent supports 艾特 prefix', async () => { }), ); - const result = await router.resolveTargetsAndIntent('艾特宪宪 看一下这个', 'thread-voice'); + // P1-4: @宪宪 removed from breeds → use @布偶猫 + const result = await router.resolveTargetsAndIntent('艾特布偶猫 看一下这个', 'thread-voice'); assert.deepEqual(result.targetCats, ['opus']); }); @@ -125,7 +127,8 @@ test('resolveTargetsAndIntent keeps existing @mentions unchanged', async () => { }), ); - const result = await router.resolveTargetsAndIntent('@砚砚 看下这个', 'thread-voice'); + // P1-4: @砚砚 removed from breeds → use @缅因猫 (valid codex alias) + const result = await router.resolveTargetsAndIntent('@缅因猫 看下这个', 'thread-voice'); assert.deepEqual(result.targetCats, ['codex']); }); @@ -141,6 +144,7 @@ test('resolveTargetsAndIntent supports @。 speech punctuation prefix', async () }), ); - const result = await router.resolveTargetsAndIntent('@。砚砚 出来一下', 'thread-voice'); + // P1-4: @砚砚 removed from breeds → use @缅因猫 (valid codex alias) + const result = await router.resolveTargetsAndIntent('@。缅因猫 出来一下', 'thread-voice'); assert.deepEqual(result.targetCats, ['codex']); }); diff --git a/packages/api/test/agent-router.test.js b/packages/api/test/agent-router.test.js index 890fe5fd70..49cb0f2dee 100644 --- a/packages/api/test/agent-router.test.js +++ b/packages/api/test/agent-router.test.js @@ -2002,6 +2002,7 @@ describe('AgentRouter', () => { const store = createMockMessageStore(); store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'earlier question', @@ -2010,6 +2011,7 @@ describe('AgentRouter', () => { threadId: 'default', }); store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'opus', content: 'earlier answer', @@ -2058,6 +2060,7 @@ describe('AgentRouter', () => { const store = createMockMessageStore(); store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'gemini', content: 'gemini said something', @@ -2105,6 +2108,7 @@ describe('AgentRouter', () => { const store = createMockMessageStore(); store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'user said hi', @@ -2966,6 +2970,7 @@ describe('#58: preferredCats candidate scope (not dispatch list)', () => { const baseTs = Date.now() - 5000; // recent — within Z5 1h time window // user msg1 @ codex + opus messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: '@codex @opus think about this', @@ -2975,6 +2980,7 @@ describe('#58: preferredCats candidate scope (not dispatch list)', () => { }); // gemini (vision guard cat) replied — would normally win lastMessageAt messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: null, catId: 'gemini', content: '愿景守护对照表 done', @@ -3020,6 +3026,7 @@ describe('#58: preferredCats candidate scope (not dispatch list)', () => { const baseTs = Date.now() - 5000; // recent — within Z5 1h time window // user msg @ opus messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: '@opus do this', @@ -3029,6 +3036,7 @@ describe('#58: preferredCats candidate scope (not dispatch list)', () => { }); // opus replied with @codex (A2A handoff) — has both userId AND catId, NOT a user message messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: null, catId: 'opus', content: '@codex 你来 review', @@ -3121,6 +3129,7 @@ describe('#58: preferredCats candidate scope (not dispatch list)', () => { // user msg @ codex messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: '@codex 这个怎么处理', @@ -3131,6 +3140,7 @@ describe('#58: preferredCats candidate scope (not dispatch list)', () => { // 6 cat/vision-guard messages between (would挤出 5-thread-message window if window 取 thread msgs) for (let i = 0; i < 6; i += 1) { messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: null, catId: i % 2 === 0 ? 'gemini' : 'opus', content: `cat msg ${i} (vision guard / handoff)`, @@ -3178,6 +3188,7 @@ describe('#58: preferredCats candidate scope (not dispatch list)', () => { // 远古 user msg @ codex messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: '@codex 老问题', @@ -3213,6 +3224,7 @@ describe('#58: preferredCats candidate scope (not dispatch list)', () => { // user msg @ codex (oldest in thread) messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: '@codex 这个怎么处理', @@ -3224,6 +3236,7 @@ describe('#58: preferredCats candidate scope (not dispatch list)', () => { // R3's "fetch 50" still missed user mention because user @ would be page-2 territory. for (let i = 0; i < 51; i += 1) { messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: null, catId: i % 2 === 0 ? 'gemini' : 'opus', content: `cat msg ${i} (vision guard / handoff)`, @@ -3271,6 +3284,7 @@ describe('#58: preferredCats candidate scope (not dispatch list)', () => { // 真正的 user msg @ codex (oldest) messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: '@codex 这个怎么处理', @@ -3283,6 +3297,7 @@ describe('#58: preferredCats candidate scope (not dispatch list)', () => { // 的 user @ codex → fallback 退化到 participantsWithActivity (gemini) for (let i = 0; i < 5; i += 1) { messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, userId: 'system', catId: null, content: `[SYS] 自动通知 #${i}`, @@ -3334,6 +3349,7 @@ describe('#58: preferredCats candidate scope (not dispatch list)', () => { // 300 条 cat msgs 全部 > 1h ago (no user msg at all) for (let i = 0; i < 300; i += 1) { messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: null, catId: i % 2 === 0 ? 'gemini' : 'opus', content: `vision-guard ancient ${i}`, @@ -3394,6 +3410,7 @@ describe('#58: preferredCats candidate scope (not dispatch list)', () => { // msg 1 (oldest): user @ codex within 1h (recent enough) messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: '@codex 这个怎么处理', @@ -3404,6 +3421,7 @@ describe('#58: preferredCats candidate scope (not dispatch list)', () => { // 300 cat / vision-guard messages between (would trip Z5_MAX_PAGES * Z5_PAGE_SIZE = 250 cap) for (let i = 0; i < 300; i += 1) { messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: null, catId: i % 2 === 0 ? 'gemini' : 'opus', content: `vision-guard / handoff msg ${i}`, @@ -3453,6 +3471,7 @@ describe('#58: preferredCats candidate scope (not dispatch list)', () => { // msg 1: 真正的 recent user @ codex (oldest by score in this scenario) messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: '@codex 这个怎么处理', @@ -3463,6 +3482,7 @@ describe('#58: preferredCats candidate scope (not dispatch list)', () => { // msg 2: re-delivered system msg — 落到 page 1 boundary (最旧 score in page 1). // 关键: timestamp << deliveredAt 让 cursor=oldest.timestamp 跳到老 send-time。 const redelivered = messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, userId: 'system', catId: null, content: '[Re-delivered] queued 2h ago, just delivered', @@ -3476,6 +3496,7 @@ describe('#58: preferredCats candidate scope (not dispatch list)', () => { // 让 page 1 (top 50 by score) = [msg2, msg3, ..., msg51],page[0] = msg2 (re-delivered)。 for (let i = 0; i < 49; i += 1) { messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: null, catId: i % 2 === 0 ? 'gemini' : 'opus', content: `cat msg ${i}`, @@ -3528,6 +3549,7 @@ describe('#58: preferredCats candidate scope (not dispatch list)', () => { // msg 1 (id 0001): 真正的 recent user @ codex messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: '@codex 这个怎么处理', @@ -3538,6 +3560,7 @@ describe('#58: preferredCats candidate scope (not dispatch list)', () => { // msg 2 (id 0002): system 消息 — timestamp 老 (2h 前) 但被 markDelivered 后排到 recent slot // 在 mock 里通过 id 顺序模拟「较新的 list 位置」(real Redis 用 score)。 messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, userId: 'system', catId: null, content: '[Re-delivered] 老消息但刚被推给 user', @@ -3581,6 +3604,7 @@ describe('#58: preferredCats candidate scope (not dispatch list)', () => { // 真正的 user msg @ codex (oldest) messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: '@codex 这个怎么处理', @@ -3592,6 +3616,7 @@ describe('#58: preferredCats candidate scope (not dispatch list)', () => { // R5 只排除了 'system',scheduler 仍被算进 user count → 真正 user mention 被挤出 for (let i = 0; i < 5; i += 1) { messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'scheduler', catId: null, content: `[Scheduler] 任务触发 #${i}`, @@ -3761,6 +3786,7 @@ describe('F229: Concierge thread routing (duty-cat always takes priority)', () = // Simulate: previous user message had @gemini mention const messageStore = createMockMessageStore(); messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, threadId: 't_concierge_mention', role: 'user', content: '@gemini hello', diff --git a/packages/api/test/auto-reply-to-worklist.test.js b/packages/api/test/auto-reply-to-worklist.test.js index 31261a9786..7c2347cf88 100644 --- a/packages/api/test/auto-reply-to-worklist.test.js +++ b/packages/api/test/auto-reply-to-worklist.test.js @@ -61,6 +61,7 @@ describe('auto-replyTo: worklist path (a2aTriggerMessageId)', () => { test('auto-fills replyTo from a2aTriggerMessageId (not user message)', async () => { // 1. User's original message (what InvocationRecordStore.userMessageId points to) const userMsg = messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: '请三只猫讨论', @@ -71,6 +72,7 @@ describe('auto-replyTo: worklist path (a2aTriggerMessageId)', () => { // 2. Cat A's message that @mentions Cat B (the actual A2A trigger) const catAMsg = messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'opus', content: '砚砚帮我看看\n@codex', @@ -124,6 +126,7 @@ describe('auto-replyTo: worklist path (a2aTriggerMessageId)', () => { test('re-mentioned pending cat gets latest triggerMessageId', async () => { const catAMsg = messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'opus', content: '帮我看看\n@sonnet', @@ -133,6 +136,7 @@ describe('auto-replyTo: worklist path (a2aTriggerMessageId)', () => { }); const catBMsg = messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'codex', content: 'sonnet 你也看看\n@sonnet', diff --git a/packages/api/test/auto-reply-to.test.js b/packages/api/test/auto-reply-to.test.js index 32dedc41cc..fa0484a374 100644 --- a/packages/api/test/auto-reply-to.test.js +++ b/packages/api/test/auto-reply-to.test.js @@ -63,6 +63,7 @@ describe('auto-replyTo for A2A invocations', () => { test('auto-fills replyTo from trigger message when cat does not pass replyTo', async () => { // 1. Simulate the trigger message (cat A @mentions cat B) const triggerMsg = messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'opus', content: '请帮忙看一下\n@codex', @@ -130,6 +131,7 @@ describe('auto-replyTo for A2A invocations', () => { test('explicit replyTo takes precedence over auto-fill', async () => { // Trigger message const triggerMsg = messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'opus', content: '请看一下\n@codex', @@ -140,6 +142,7 @@ describe('auto-replyTo for A2A invocations', () => { // A different message the cat wants to reply to explicitly const otherMsg = messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: '用户的另一条消息', @@ -217,6 +220,7 @@ describe('auto-replyTo for A2A invocations', () => { test('P3-2: no auto-fill when parentInvocationRecord threadId mismatches', async () => { // Trigger message exists in thread-1 const triggerMsg = messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'opus', content: '请看\n@codex', @@ -268,6 +272,7 @@ describe('auto-replyTo for A2A invocations', () => { test('no auto-fill when trigger message is in different thread', async () => { // Trigger message in thread-1 const triggerMsg = messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'opus', content: '请看\n@codex', diff --git a/packages/api/test/ball-custody-state-machine.test.js b/packages/api/test/ball-custody-state-machine.test.js index 285d90e813..366304aa9e 100644 --- a/packages/api/test/ball-custody-state-machine.test.js +++ b/packages/api/test/ball-custody-state-machine.test.js @@ -203,6 +203,15 @@ describe('ball-custody transition — 虚空 + 唤醒', () => { assert.deepStrictEqual(transition('active', ev('ball.void_pass'), snap()), { ok: true, next: 'void' }); assert.deepStrictEqual(transition('blocked', ev('ball.void_pass'), snap()), { ok: true, next: 'void' }); }); + it('ball.void_ack new/active/blocked/parked → void(LI-005)', () => { + for (const from of ['new', 'active', 'blocked', 'parked']) { + assert.deepStrictEqual(transition(from, ev('ball.void_ack'), snap()), { ok: true, next: 'void' }); + } + // dead/void/zombie/resolved → reject + for (const from of ['dead', 'void', 'zombie', 'resolved']) { + assert.strictEqual(transition(from, ev('ball.void_ack'), snap()).ok, false); + } + }); it('ball.wake_sent blocked → blocked(informational,lastWakeAt 由 projector 更新)', () => { assert.deepStrictEqual(transition('blocked', ev('ball.wake_sent'), snap()), { ok: true, next: 'blocked' }); }); @@ -215,7 +224,7 @@ describe('ball-custody transition — 虚空 + 唤醒', () => { describe('INV-10 完整性穷举:全 state × event 无未定义', () => { it('每个 (state, event) transition 返回 well-formed result,不 throw', () => { assert.strictEqual(ALL_BALL_STATES.length, 8); // new + 7 - assert.strictEqual(ALL_BALL_EVENT_KINDS.length, 17); // Phase B 13 + Phase C 3 安乐死 + Phase P 1 wakeWhen + assert.strictEqual(ALL_BALL_EVENT_KINDS.length, 18); // Phase B 13 + Phase C 3 安乐死 + Phase P 1 wakeWhen + LI-005 1 void_ack for (const state of ALL_BALL_STATES) { for (const kind of ALL_BALL_EVENT_KINDS) { const r = transition( diff --git a/packages/api/test/bg-transcript-parity.test.js b/packages/api/test/bg-transcript-parity.test.js index 68b549f7fc..972c37ceeb 100644 --- a/packages/api/test/bg-transcript-parity.test.js +++ b/packages/api/test/bg-transcript-parity.test.js @@ -419,3 +419,56 @@ test('accumulateUsageFromEntries: real+synthetic mix → only real turn counted assert.equal(usage.numTurns, 1, 'numTurns must be 1 for a single real turn'); assert.equal(usage.outputTokens, 5, 'token counts from the real turn must be preserved'); }); + +// ─── LI-005: user entry → tool_result bridge ─────────────────────────── + +test('LI-005: user entries with tool_result blocks emit tool_result AgentMessages', () => { + const entries = [ + { + type: 'user', + message: { + content: [ + { + type: 'tool_result', + tool_use_id: 'toolu_hold', + content: '{"status":"ok","held":true}', + is_error: false, + }, + ], + }, + }, + ]; + const out = transcriptEntriesToAgentMessages(entries, { catId: CAT_ID }); + assert.equal(out.length, 1, 'should emit one tool_result'); + assert.equal(out[0].type, 'tool_result'); + assert.equal(out[0].toolResultStatus, 'ok'); + assert.equal(out[0].content, '{"status":"ok","held":true}'); + assert.equal(out[0].toolUseId, 'toolu_hold'); +}); + +test('LI-005: user entries with is_error:true emit error toolResultStatus', () => { + const entries = [ + { + type: 'user', + message: { + content: [ + { + type: 'tool_result', + tool_use_id: 'toolu_fail', + content: 'Rate limit exceeded', + is_error: true, + }, + ], + }, + }, + ]; + const out = transcriptEntriesToAgentMessages(entries, { catId: CAT_ID }); + assert.equal(out.length, 1); + assert.equal(out[0].toolResultStatus, 'error'); +}); + +test('LI-005: user entries without tool_result blocks are skipped', () => { + const entries = [{ type: 'user', message: { content: [{ type: 'text', text: 'hello' }] } }]; + const out = transcriptEntriesToAgentMessages(entries, { catId: CAT_ID }); + assert.equal(out.length, 0, 'non-tool_result user content → no output'); +}); diff --git a/packages/api/test/callback-a2a-postmsg.test.js b/packages/api/test/callback-a2a-postmsg.test.js index 5a99ddd125..21787affaf 100644 --- a/packages/api/test/callback-a2a-postmsg.test.js +++ b/packages/api/test/callback-a2a-postmsg.test.js @@ -207,6 +207,46 @@ describe('post_message A2A mention invocation', () => { assert.deepEqual(invocationRecordStore.getRecords()[0].targetCats, ['codex']); }); + // F257 #4 — O2→O1 signature lint wiring: the post seam records extra.signatureLint + // observe-only (non-blocking) on text-bearing agent messages (dev-7a882ba0 class). + test('post-message WITHOUT trailing signature records extra.signatureLint.signed=false', async () => { + const app = await createApp(); + const { invocationId, callbackToken } = await registry.create('user-1', 'opus', { threadId: 't1' }); + + const response = await app.inject({ + method: 'POST', + url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { content: 'LGTM, merging now.' }, + }); + + assert.equal(response.statusCode, 200); + const recent = messageStore.getRecent(10); + assert.equal(recent.length, 1); + assert.deepEqual(recent[0].extra?.signatureLint, { signed: false }); + // P2-1 live-broadcast reachability: the socket delivery carries the verdict too. + assert.deepEqual(socketManager.getMessages().at(-1)?.extra?.signatureLint, { signed: false }); + }); + + test('post-message WITH trailing signature records extra.signatureLint.signed=true', async () => { + const app = await createApp(); + const { invocationId, callbackToken } = await registry.create('user-1', 'opus', { threadId: 't1' }); + + const response = await app.inject({ + method: 'POST', + url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { content: '修复完成了,已跑通 gate。\n\n[砚砚/Codex🐾]' }, + }); + + assert.equal(response.statusCode, 200); + const recent = messageStore.getRecent(10); + assert.equal(recent.length, 1); + assert.deepEqual(recent[0].extra?.signatureLint, { signed: true }); + // P2-1 live-broadcast reachability: the socket delivery carries the verdict too. + assert.deepEqual(socketManager.getMessages().at(-1)?.extra?.signatureLint, { signed: true }); + }); + test('post-message duplicate retry recovers a queued A2A callback before returning duplicate', async () => { const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); const queueProcessor = { @@ -221,6 +261,7 @@ describe('post_message A2A mention invocation', () => { const content = 'same queued callback report needing A2A recovery'; const queued = messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'opus', content, diff --git a/packages/api/test/callback-docs-route.test.js b/packages/api/test/callback-docs-route.test.js index 4ca45e17dc..58568acf4d 100644 --- a/packages/api/test/callback-docs-route.test.js +++ b/packages/api/test/callback-docs-route.test.js @@ -3,10 +3,10 @@ import { describe, test } from 'node:test'; import Fastify from 'fastify'; describe('Callback Docs Routes', () => { - async function createApp() { + async function createApp(opts = {}) { const { registerCallbackDocsRoutes } = await import('../dist/routes/callback-docs-routes.js'); const app = Fastify(); - await app.register(registerCallbackDocsRoutes); + await app.register(registerCallbackDocsRoutes, opts); await app.ready(); return app; } @@ -43,4 +43,44 @@ describe('Callback Docs Routes', () => { await app.close(); } }); + + // F257 #3: objective registry discovery route — serves the shipped registry.yaml + // so cat_cafe_list_objectives can surface valid objectiveIds (no archaeology). + test('GET /api/callbacks/objectives returns 200 with canonized objectives', async () => { + const app = await createApp(); + try { + const response = await app.inject({ method: 'GET', url: '/api/callbacks/objectives' }); + assert.equal(response.statusCode, 200); + const body = response.json(); + assert.ok(Array.isArray(body.objectives), 'response should have objectives array'); + const ids = body.objectives.map((o) => o.id); + assert.ok(ids.includes('obj-routing-delivery'), 'obj-routing-delivery served'); + assert.ok(ids.includes('obj-identity-integrity'), 'obj-identity-integrity served'); + for (const o of body.objectives) { + assert.ok(o.id && o.statement, 'each objective has id + statement'); + assert.equal('segments' in o, false, 'no segments authority in served objective'); + } + } finally { + await app.close(); + } + }); + + // 2a R1 P1-2: an unreadable/invalid registry must fail-closed (503), never a + // cacheable 200 empty list that masquerades as "no objectives". + // 2a R2 P2-1: the unauthenticated 503 must NOT leak the internal path / fs errno. + test('GET /api/callbacks/objectives returns a path-free 503 when registry unreadable', async () => { + const secretPath = '/private/secret-install/objectives-registry.yaml'; + const app = await createApp({ objectiveRegistryPath: secretPath }); + try { + const response = await app.inject({ method: 'GET', url: '/api/callbacks/objectives' }); + assert.equal(response.statusCode, 503); + const body = response.json(); + assert.match(body.error, /unavailable/i, 'surfaces an explicit unavailability error'); + assert.doesNotMatch(body.error, /secret-install/, 'must not leak the install path'); + assert.doesNotMatch(body.error, /ENOENT|errno|no such file/i, 'must not leak fs errno'); + assert.equal(response.headers['cache-control'], undefined, 'failure is not cached'); + } finally { + await app.close(); + } + }); }); diff --git a/packages/api/test/callback-guard-rejection-route.test.js b/packages/api/test/callback-guard-rejection-route.test.js new file mode 100644 index 0000000000..321d843cd4 --- /dev/null +++ b/packages/api/test/callback-guard-rejection-route.test.js @@ -0,0 +1,403 @@ +/** + * F257 V2/Phase B — MCP client-layer guard rejection ingest tests (AC-B1). + * + * Trust-boundary contract under test: + * - identity (catId/threadId/invocationId) comes from the auth record, NEVER + * from the payload — spoofed payload identity fields must be ignored + * - guardId is whitelisted against the ledger registry (fail-closed) + * - eventId/timestamp are server-generated; layer='mcp-client'; + * correlationConfidence='exact' (auth-token-bound invocationId) + */ + +import assert from 'node:assert/strict'; +import { beforeEach, describe, mock, test } from 'node:test'; +import Fastify from 'fastify'; + +describe('F257 V2: /api/callbacks/guard-rejections ingest', () => { + let registry; + let threadStore; + + beforeEach(async () => { + const { InvocationRegistry } = await import( + '../dist/domains/cats/services/agents/invocation/InvocationRegistry.js' + ); + const { ThreadStore } = await import('../dist/domains/cats/services/stores/ports/ThreadStore.js'); + registry = new InvocationRegistry(); + threadStore = new ThreadStore(); + }); + + function makeFakeLog() { + const appended = []; + return { + append: mock.fn(async (event) => { + appended.push(event); + }), + // In-memory ledgerId query — mirrors fetchWindow filter semantics so the + // POST → GET e2e loop closes without Redis. + async queryWindowComplete(opts) { + return this.queryWindowStrictComplete(opts); + }, + async queryWindowStrictComplete(opts) { + const events = appended.filter( + (e) => + (!opts.ledgerId || e.ledgerId === opts.ledgerId) && + (!opts.ownerUserId || e.ownerUserId === opts.ownerUserId) && + e.timestamp >= opts.since && + e.timestamp < (opts.until ?? Number.POSITIVE_INFINITY), + ); + return { events, truncated: false }; + }, + _appended: appended, + }; + } + + async function createApp(guardRejectionLog, extra = {}) { + const { callbacksRoutes } = await import('../dist/routes/callbacks.js'); + const app = Fastify(); + await app.register(callbacksRoutes, { + registry, + messageStore: { + async getMessagesForThread() { + return []; + }, + }, + socketManager: { + broadcastAgentMessage() {}, + getMessages() { + return []; + }, + }, + threadStore, + evidenceStore: { + async store() {}, + async search() { + return []; + }, + }, + markerQueue: { enqueue() {} }, + reflectionService: { async run() {} }, + holdBallDeps: { + registry, + taskRunner: { registerDynamic() {}, unregister() {} }, + templateRegistry: { get() {} }, + dynamicTaskStore: { insert() {}, getAll: () => [], remove: () => true }, + messageStore: { async append() {} }, + socketManager: { broadcastToRoom() {} }, + guardRejectionLog, + }, + ...extra, + }); + return app; + } + + test('401 when callback auth headers are missing', async () => { + const log = makeFakeLog(); + const app = await createApp(log); + const response = await app.inject({ + method: 'POST', + url: '/api/callbacks/guard-rejections', + payload: { + kind: 'http_policy_reject', + guardId: 'cross_post_routing_credentials', + sourceTool: 'cross_post_message', + normalizedReason: 'no_routing_credentials', + }, + }); + assert.equal(response.statusCode, 401); + assert.equal(log._appended.length, 0, 'nothing appended without auth'); + }); + + test('400 on invalid kind (not an MCP-producible kind)', async () => { + const log = makeFakeLog(); + const app = await createApp(log); + const thread = await threadStore.create('user-gr-1', 'gr1'); + const { invocationId, callbackToken } = await registry.create('user-gr-1', 'codex', thread.id); + const response = await app.inject({ + method: 'POST', + url: '/api/callbacks/guard-rejections', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { + kind: 'http_rate_limit', // server-side kind, not MCP-local + guardId: 'cross_post_routing_credentials', + sourceTool: 'cross_post_message', + normalizedReason: 'no_routing_credentials', + }, + }); + assert.equal(response.statusCode, 400); + assert.equal(log._appended.length, 0); + }); + + test('sol P1-3 regression: prototype-chain guardIds are rejected (toString/constructor/__proto__)', async () => { + const log = makeFakeLog(); + const app = await createApp(log); + const thread = await threadStore.create('user-gr-proto', 'grproto'); + const { invocationId, callbackToken } = await registry.create('user-gr-proto', 'codex', thread.id); + + for (const protoKey of ['toString', 'constructor', '__proto__']) { + const response = await app.inject({ + method: 'POST', + url: '/api/callbacks/guard-rejections', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { + kind: 'http_policy_reject', + guardId: protoKey, + sourceTool: 'x', + normalizedReason: 'y', + }, + }); + assert.equal(response.statusCode, 400, `prototype key '${protoKey}' must be rejected (was 202 pre-fix)`); + } + assert.equal(log._appended.length, 0, 'no prototype-key event may reach the ledger'); + }); + + test('sol P1-1 regression: agent-key principal is accepted; payload thread verified via scoped resolver', async () => { + const log = makeFakeLog(); + // Fake agent-key registry: secret 'ak-good' → cat 'antigravity' owned by user-ak. + const agentKeyRegistry = { + async verify(secret) { + if (secret !== 'ak-good') return { ok: false, reason: 'unknown_key' }; + return { ok: true, record: { agentKeyId: 'ak-1', userId: 'user-ak', catId: 'antigravity' } }; + }, + }; + const app = await createApp(log, { agentKeyRegistry }); + const ownThread = await threadStore.create('user-ak', 'ak-own'); + const foreignThread = await threadStore.create('user-other', 'ak-foreign'); + + // Own thread coordinate → verified and attributed. + const ok = await app.inject({ + method: 'POST', + url: '/api/callbacks/guard-rejections', + headers: { 'x-agent-key-secret': 'ak-good' }, + payload: { + kind: 'http_policy_reject', + guardId: 'cross_post_routing_credentials', + sourceTool: 'cross_post_message', + normalizedReason: 'no_routing_credentials', + threadId: ownThread.id, + }, + }); + assert.equal(ok.statusCode, 202, 'agent-key principal must be accepted (was 401 pre-fix)'); + assert.equal(log._appended.length, 1); + assert.equal(log._appended[0].catId, 'antigravity'); + assert.equal(log._appended[0].threadId, ownThread.id, 'verified own thread attributed'); + assert.equal(log._appended[0].invocationId, 'unknown', 'agent-key has no invocation binding'); + assert.equal(log._appended[0].correlationConfidence, 'window'); + + // Foreign thread coordinate → degrades to unknown, never attributed. + const foreign = await app.inject({ + method: 'POST', + url: '/api/callbacks/guard-rejections', + headers: { 'x-agent-key-secret': 'ak-good' }, + payload: { + kind: 'http_policy_reject', + guardId: 'cross_post_routing_credentials', + sourceTool: 'cross_post_message', + normalizedReason: 'no_routing_credentials', + threadId: foreignThread.id, + }, + }); + assert.equal(foreign.statusCode, 202, 'observation is kept even when thread verification fails'); + assert.equal( + log._appended[1].threadId, + 'unknown', + 'foreign thread must NOT be attributed (scoped resolver denied)', + ); + }); + + test('sol P1-4 e2e: rejection-response ledgerId queries back both events + stats with how_counted', async () => { + const log = makeFakeLog(); + const fakeStatsRedis = { + sets: new Map(), + async sadd(key, member) { + const s = this.sets.get(key) ?? new Set(); + s.add(member); + this.sets.set(key, s); + return 1; + }, + async scard(key) { + return this.sets.get(key)?.size ?? 0; + }, + // callbacks.ts constructs GuardLedgerStats from opts.redis — provide both ops. + }; + const app = await createApp(log, { redis: fakeStatsRedis }); + const thread = await threadStore.create('user-gr-q', 'grq'); + const { invocationId, callbackToken } = await registry.create('user-gr-q', 'codex', thread.id); + + // Ingest an MCP-local reject → response hands us the ledgerId. + const post = await app.inject({ + method: 'POST', + url: '/api/callbacks/guard-rejections', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { + kind: 'http_policy_reject', + guardId: 'cross_post_routing_credentials', + sourceTool: 'cross_post_message', + normalizedReason: 'no_routing_credentials', + }, + }); + assert.equal(post.statusCode, 202); + const { ledgerId } = JSON.parse(post.body); + + // A same-pot API-route event already in the ledger (spec acceptance shape: + // "one 429-style route event + one MCP reject → query by ledger id → both"). + await log.append({ + eventId: 'evt-route-1', + ledgerId, + kind: 'http_policy_reject', + threadId: thread.id, + catId: 'codex', + guardId: 'cross_post_routing_credentials', + ownerUserId: 'user-gr-q', + invocationId: 'unknown', + sourceTool: 'cross_post_message', + normalizedReason: 'no_routing_credentials', + layer: 'api-route', + timestamp: Date.now(), + correlationConfidence: 'window', + }); + + // Stats: one anomaly reference recorded for this pot. + fakeStatsRedis.sets.set(`guard-ledger:stats:user-gr-q:${ledgerId}:anomaly-refs`, new Set(['dev-1'])); + + const get = await app.inject({ + method: 'GET', + url: `/api/callbacks/guard-rejections?ledgerId=${encodeURIComponent(ledgerId)}`, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + }); + assert.equal(get.statusCode, 200); + const body = JSON.parse(get.body); + assert.equal(body.ledgerId, ledgerId); + assert.equal(body.events.length, 2, 'query by ledgerId returns BOTH layers (mcp-client + api-route)'); + assert.deepEqual(new Set(body.events.map((e) => e.layer)), new Set(['mcp-client', 'api-route'])); + assert.equal(body.stats.anomalyRefCount, 1, 'AC-B2 stats exposed on the query surface'); + assert.ok(body.stats.howCounted.includes('scard'), 'how_counted travels with the stat'); + assert.equal(body.truncated, false); + }); + + test('sol P1-2 e2e: anomaly report referencing a ledgerId writes pot stats through the ROUTE', async () => { + const log = makeFakeLog(); + const fakeStatsRedis = { + sets: new Map(), + async sadd(key, member) { + const s = this.sets.get(key) ?? new Set(); + s.add(member); + this.sets.set(key, s); + return 1; + }, + async scard(key) { + return this.sets.get(key)?.size ?? 0; + }, + }; + const anchorMsg = { id: 'm-anchor-1', threadId: 'thread-rep', userId: 'user-rep' }; + const fakeDeviationLog = { + async append(event) { + return { outcome: 'appended', eventId: event.eventId }; + }, + async query() { + return { events: [], nextCursor: null, missingBodies: [] }; + }, + }; + const app = await createApp(log, { + redis: fakeStatsRedis, + deviationEventLog: fakeDeviationLog, + messageStore: { + async getMessagesForThread() { + return []; + }, + async getById(id) { + return id === anchorMsg.id ? anchorMsg : null; + }, + }, + }); + const thread = await threadStore.create('user-rep', 'rep1'); + const { invocationId, callbackToken } = await registry.create('user-rep', 'codex', thread.id); + + const response = await app.inject({ + method: 'POST', + url: '/api/callbacks/harness-signals/report', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { + subjectCatId: 'codex', + source: 'self', + note: 'hit 429 twice; rejection carried ledger mcp/hold-ball-rate-limit — reporting per F257 V2', + sourceAnchor: { kind: 'thread_message', messageId: anchorMsg.id }, + attributions: [ + { objectiveId: 'obj-routing-delivery', unitRefs: [{ unitType: 'segment', unitId: 'S1' }], weight: 1 }, + ], + }, + }); + assert.equal(response.statusCode, 200, `report route must succeed, got ${response.body}`); + + // sol P1-2: pre-fix this returned 200 with statsWrites=0 (route adapter + // dropped ledgerStats). Now the write side records the reference. + const statsKey = 'guard-ledger:stats:user-rep:mcp/hold-ball-rate-limit:anomaly-refs'; + const statsSet = fakeStatsRedis.sets.get(statsKey); + assert.ok(statsSet && statsSet.size === 1, `stats must be written through the route (got ${statsSet?.size ?? 0})`); + }); + + test('400 on unregistered guardId (fail-closed whitelist)', async () => { + const log = makeFakeLog(); + const app = await createApp(log); + const thread = await threadStore.create('user-gr-2', 'gr2'); + const { invocationId, callbackToken } = await registry.create('user-gr-2', 'codex', thread.id); + const response = await app.inject({ + method: 'POST', + url: '/api/callbacks/guard-rejections', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { + kind: 'http_policy_reject', + guardId: 'made_up_guard', + sourceTool: 'whatever', + normalizedReason: 'whatever', + }, + }); + assert.equal(response.statusCode, 400); + const body = JSON.parse(response.body); + assert.ok(body.error.includes('unregistered guardId'), 'error names the whitelist failure'); + assert.equal(log._appended.length, 0, 'unregistered guard must not enter the ledger'); + }); + + test('202: identity comes from auth record, spoofed payload identity ignored, octet complete', async () => { + const log = makeFakeLog(); + const app = await createApp(log); + const thread = await threadStore.create('user-gr-3', 'gr3'); + const { invocationId, callbackToken } = await registry.create('user-gr-3', 'codex', thread.id); + const response = await app.inject({ + method: 'POST', + url: '/api/callbacks/guard-rejections', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { + kind: 'http_policy_reject', + guardId: 'cross_post_routing_credentials', + sourceTool: 'cross_post_message', + normalizedReason: 'no_routing_credentials', + // Spoof attempts — schema strips unknown fields; identity must come + // from the auth record (V1 three-axis provenance discipline). + catId: 'evil-cat', + threadId: 'evil-thread', + invocationId: 'evil-invocation', + timestamp: 1, + }, + }); + assert.equal(response.statusCode, 202); + const body = JSON.parse(response.body); + assert.equal(body.accepted, true); + assert.equal(body.ledgerId, 'mcp/cross-post-routing-credentials', 'response carries the pot coordinate'); + + assert.equal(log._appended.length, 1); + const event = log._appended[0]; + assert.equal(event.catId, 'codex', 'catId from auth record, not payload'); + assert.equal(event.threadId, thread.id, 'threadId from auth record, not payload'); + assert.equal(event.invocationId, invocationId, 'invocationId from auth record, not payload'); + assert.notEqual(event.timestamp, 1, 'timestamp server-generated'); + assert.equal(event.kind, 'http_policy_reject'); + assert.equal(event.guardId, 'cross_post_routing_credentials'); + assert.equal(event.ledgerId, 'mcp/cross-post-routing-credentials'); + assert.equal(event.sourceTool, 'cross_post_message'); + assert.equal(event.normalizedReason, 'no_routing_credentials'); + assert.equal(event.layer, 'mcp-client'); + assert.equal(event.correlationConfidence, 'exact', 'auth-bound invocationId → exact'); + assert.ok(event.eventId, 'server-generated eventId present'); + assert.equal(body.eventId, event.eventId, 'response eventId matches appended event'); + }); +}); diff --git a/packages/api/test/callback-hold-ball-wakewhen.test.js b/packages/api/test/callback-hold-ball-wakewhen.test.js index f3a29d143c..66b67a2008 100644 --- a/packages/api/test/callback-hold-ball-wakewhen.test.js +++ b/packages/api/test/callback-hold-ball-wakewhen.test.js @@ -274,4 +274,38 @@ describe('F167 Phase P: wakeWhen cancel/replace/delivery tests', () => { 'fallback task should NOT be removed when wake delivery fails — cat needs the fallback wake', ); }); + + test('F257 LI-001: wakeWhen command completion opts invocation into action liveness', async () => { + const triggerCalls = []; + const deps = makeStubDeps({ + invokeTrigger: { + async trigger(...args) { + triggerCalls.push(args); + }, + }, + }); + const app = await createApp(deps); + const thread = await threadStore.create('user-hb-li001', 'hb-li001'); + const { invocationId, callbackToken } = await registry.create('user-hb-li001', 'codex', thread.id); + + const response = await app.inject({ + method: 'POST', + url: '/api/callbacks/hold-ball', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { + reason: 'run quick probe', + nextStep: 'inspect quick probe', + wakeWhen: { command: 'echo done' }, + }, + }); + assert.equal(response.statusCode, 200); + + const startedAt = Date.now(); + while (triggerCalls.length === 0 && Date.now() - startedAt < 2_000) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + + assert.equal(triggerCalls.length, 1, 'command completion should trigger exactly one wake invocation'); + assert.equal(triggerCalls[0][6]?.completionRequirement, 'action-or-routing-exit'); + }); }); diff --git a/packages/api/test/callback-propose-profile-update-routes.test.js b/packages/api/test/callback-propose-profile-update-routes.test.js index 3092969b55..d37742d55b 100644 --- a/packages/api/test/callback-propose-profile-update-routes.test.js +++ b/packages/api/test/callback-propose-profile-update-routes.test.js @@ -182,6 +182,7 @@ describe('callback propose-profile-update route', () => { createdBy: 'alice', }); const cardMessage = await messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'alice', catId: 'opus', content: 'visible profile update card', @@ -197,6 +198,7 @@ describe('callback propose-profile-update route', () => { }); for (let i = 0; i < 600; i += 1) { await messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'alice', catId: null, content: `newer ${i}`, diff --git a/packages/api/test/callback-routes-agent-key.test.js b/packages/api/test/callback-routes-agent-key.test.js index a626d3e9b2..4a5a0f699f 100644 --- a/packages/api/test/callback-routes-agent-key.test.js +++ b/packages/api/test/callback-routes-agent-key.test.js @@ -398,6 +398,7 @@ describe('Callback routes: agent-key auth path', () => { const { secret } = await issueKey(); const queued = messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: TEST_USER, catId: TEST_CAT, content: 'same queued smoke report', diff --git a/packages/api/test/callback-routes.test.js b/packages/api/test/callback-routes.test.js index f0c9f649c7..0194eb6137 100644 --- a/packages/api/test/callback-routes.test.js +++ b/packages/api/test/callback-routes.test.js @@ -360,6 +360,7 @@ describe('Callback Routes', () => { const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); const queued = messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'opus', content: 'same queued callback report', @@ -397,6 +398,7 @@ describe('Callback Routes', () => { const now = Date.now(); const freshDuplicate = messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'opus', content: 'same callback report behind stale tail', @@ -412,6 +414,7 @@ describe('Callback Routes', () => { }, }); messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'opus', content: 'old unrelated callback tail', @@ -441,6 +444,7 @@ describe('Callback Routes', () => { const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); const first = messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'opus', content: 'same callback payload after rich block consumption', @@ -578,7 +582,11 @@ describe('Callback Routes', () => { ); }); - test('POST post-message single content @mention ignores extra explicit targetCats (A2A fail-closed)', async () => { + // F257 增补契约演进(原名 "single content @mention ignores extra explicit targetCats"): + // 旧 content-wins 仲裁会静默丢弃声明目标只路由 content 解析猫——kickoff 活体事故 + // (声明 sol + content @砚砚 → 只路由 codex)正是此形态。新契约:content 解析出 + // 声明外的猫 → HELD,不落库不路由,返回结构化指引让发送方自纠。 + test('POST post-message content @mention outside declared targetCats → HELD (routing mismatch)', async () => { const app = await createApp(); const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); @@ -593,17 +601,14 @@ describe('Callback Routes', () => { }); assert.equal(response.statusCode, 200); - - const recent = messageStore.getRecent(10); - assert.equal(recent.length, 1); - // Single content mention should win; extras from explicit targetCats are pruned. - const mentions = recent[0].mentions; - assert.ok(mentions.includes('codex'), 'content @mention should be included'); - assert.equal(mentions.includes('gpt52'), false, 'extra explicit targetCats should be pruned'); - assert.deepEqual(recent[0].extra?.targetCats, ['gpt52']); + const body = JSON.parse(response.body); + assert.equal(body.status, 'held', 'declared/parsed mismatch must be HELD, not silently arbitrated'); + assert.equal(body.reason, 'routing_mismatch'); + assert.deepEqual(body.unexpectedTargets, ['codex']); + assert.equal(messageStore.getRecent(10).length, 0, 'held message must not be stored'); }); - test('POST post-message keeps merged targets when content has multiple @mentions', async () => { + test('POST post-message multi-mention content outside declared targetCats → HELD (routing mismatch)', async () => { const app = await createApp(); const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); @@ -617,13 +622,35 @@ describe('Callback Routes', () => { }, }); + assert.equal(response.statusCode, 200); + const body = JSON.parse(response.body); + assert.equal(body.status, 'held'); + assert.equal(body.reason, 'routing_mismatch'); + assert.deepEqual([...body.unexpectedTargets].sort(), ['codex', 'gpt52']); + assert.equal(messageStore.getRecent(10).length, 0); + }); + + test('POST post-message content @mention within declared targetCats narrows normally', async () => { + const app = await createApp(); + const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); + + const response = await app.inject({ + method: 'POST', + url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { + content: '同步一下\n@codex', + targetCats: ['codex', 'gpt52'], + }, + }); + assert.equal(response.statusCode, 200); const recent = messageStore.getRecent(10); assert.equal(recent.length, 1); + // content 单 mention 是声明子集 → 收窄到 content 目标(既有 content-narrowing 保留) const mentions = recent[0].mentions; - assert.ok(mentions.includes('codex')); - assert.ok(mentions.includes('gpt52')); - assert.ok(mentions.includes('gemini'), 'multi-mention content should still merge explicit targetCats'); + assert.ok(mentions.includes('codex'), 'content @mention should be included'); + assert.equal(mentions.includes('gpt52'), false, 'declared superset narrows to the single content mention'); }); test('POST post-message rejects cross-thread send to another user thread', async () => { @@ -653,6 +680,7 @@ describe('Callback Routes', () => { // Add some messages with mentions messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: '@opus help me', @@ -660,6 +688,7 @@ describe('Callback Routes', () => { timestamp: Date.now(), }); messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: '@codex review', @@ -711,6 +740,7 @@ describe('Callback Routes', () => { const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'Message 1', @@ -718,6 +748,7 @@ describe('Callback Routes', () => { timestamp: 1, }); messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'opus', content: 'Reply 1', @@ -760,6 +791,7 @@ describe('Callback Routes', () => { const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'look at this diagram', @@ -790,6 +822,7 @@ describe('Callback Routes', () => { for (let i = 0; i < 10; i++) { messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: `Message ${i}`, @@ -817,6 +850,7 @@ describe('Callback Routes', () => { for (let i = 0; i < 5; i++) { messages.push( messageStore.append({ + provenance: { author: i % 2 === 0 ? 'user' : 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: i % 2 === 0 ? null : 'opus', content: `Window message ${i}`, @@ -845,7 +879,14 @@ describe('Callback Routes', () => { const app = await createApp(); const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); const longBody = `${'filler '.repeat(60)}REDISLOCKBUG at the very end`; - messageStore.append({ userId: 'user-1', catId: null, content: longBody, mentions: [], timestamp: 1 }); + messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'user-1', + catId: null, + content: longBody, + mentions: [], + timestamp: 1, + }); const response = await app.inject({ method: 'GET', @@ -866,7 +907,14 @@ describe('Callback Routes', () => { test('thread-context emits returnedChars telemetry (F236 R1/砚砚 P1 eval contract)', async () => { const app = await createApp(); const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); - messageStore.append({ userId: 'user-1', catId: null, content: 'X'.repeat(500), mentions: [], timestamp: 1 }); + messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'user-1', + catId: null, + content: 'X'.repeat(500), + mentions: [], + timestamp: 1, + }); const logs = []; app.log.info = (obj) => logs.push(obj); @@ -886,6 +934,7 @@ describe('Callback Routes', () => { const app = await createApp(); const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'AAAA', @@ -894,6 +943,7 @@ describe('Callback Routes', () => { threadId: 'thread-1', }); const target = messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'TARGET', @@ -902,6 +952,7 @@ describe('Callback Routes', () => { threadId: 'thread-1', }); messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'CCCC', @@ -942,7 +993,14 @@ describe('Callback Routes', () => { const bigBody = 'Z'.repeat(2000); let fullContentChars = 0; for (let i = 0; i < 10; i++) { - messageStore.append({ userId: 'user-1', catId: null, content: bigBody, mentions: [], timestamp: i + 1 }); + messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'user-1', + catId: null, + content: bigBody, + mentions: [], + timestamp: i + 1, + }); fullContentChars += bigBody.length; } const response = await app.inject({ @@ -968,6 +1026,7 @@ describe('Callback Routes', () => { const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); const longContent = `@opus ${'detail '.repeat(80)}FINAL INSTRUCTION: ship it now`; messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: longContent, @@ -1095,6 +1154,7 @@ describe('Callback Routes', () => { const app = await createApp(); const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); const message = messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'M'.repeat(600), @@ -1171,6 +1231,7 @@ describe('Callback Routes', () => { const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); const other = messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', threadId: 'other-thread', catId: null, @@ -1193,6 +1254,7 @@ describe('Callback Routes', () => { const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'human message', @@ -1200,6 +1262,7 @@ describe('Callback Routes', () => { timestamp: 1, }); messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'opus', content: 'opus reply', @@ -1207,6 +1270,7 @@ describe('Callback Routes', () => { timestamp: 2, }); messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'codex', content: 'codex reply', @@ -1240,6 +1304,7 @@ describe('Callback Routes', () => { const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'Discuss Redis lock strategy', @@ -1247,6 +1312,7 @@ describe('Callback Routes', () => { timestamp: 1, }); messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'opus', content: 'No database updates here', @@ -1254,6 +1320,7 @@ describe('Callback Routes', () => { timestamp: 2, }); messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'codex', content: 'redis retry and timeout', @@ -1281,6 +1348,7 @@ describe('Callback Routes', () => { const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'codex', content: 'redis findings', @@ -1288,6 +1356,7 @@ describe('Callback Routes', () => { timestamp: 1, }); messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'codex', content: 'other topic', @@ -1295,6 +1364,7 @@ describe('Callback Routes', () => { timestamp: 2, }); messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'opus', content: 'redis but different cat', @@ -1336,6 +1406,7 @@ describe('Callback Routes', () => { // user-1's message messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'User 1 msg', @@ -1344,6 +1415,7 @@ describe('Callback Routes', () => { }); // user-2's message (should NOT be visible to user-1's invocation) messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-2', catId: null, content: 'User 2 msg', @@ -1368,6 +1440,7 @@ describe('Callback Routes', () => { const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'Check this screenshot', @@ -1379,6 +1452,7 @@ describe('Callback Routes', () => { timestamp: 1, }); messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'opus', content: 'I see the image', @@ -1422,6 +1496,7 @@ describe('Callback Routes', () => { // Messages in thread-A (own thread) messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'thread-A msg', @@ -1431,6 +1506,7 @@ describe('Callback Routes', () => { }); // Messages in thread-B (cross-thread target) messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'thread-B msg 1', @@ -1439,6 +1515,7 @@ describe('Callback Routes', () => { threadId: 'thread-B', }); messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'codex', content: 'thread-B msg 2', @@ -1466,6 +1543,7 @@ describe('Callback Routes', () => { const { invocationId, callbackToken } = await registry.create('user-1', 'opus', 'thread-A'); messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'thread-A msg', @@ -1474,6 +1552,7 @@ describe('Callback Routes', () => { threadId: 'thread-A', }); messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'thread-B msg', @@ -1501,6 +1580,7 @@ describe('Callback Routes', () => { // 5 messages in thread-B for (let i = 0; i < 5; i++) { messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: `thread-B msg ${i}`, @@ -2052,9 +2132,10 @@ describe('Callback Routes', () => { }); }); + // P1-4: @砚砚 removed from codex breed → use only 缅因猫 (still valid codex alias) test('GET feat-index resolves slash-separated single owner aliases', async () => { featIndexProvider = async () => [ - { featId: 'F191', name: 'Architecture Governance', status: 'done', owner: '缅因猫/砚砚' }, + { featId: 'F191', name: 'Architecture Governance', status: 'done', owner: '缅因猫/缅因' }, ]; const app = await createApp(); @@ -2072,7 +2153,7 @@ describe('Callback Routes', () => { featId: 'F191', name: 'Architecture Governance', status: 'done', - owner: '缅因猫/砚砚', + owner: '缅因猫/缅因', ownerCatId: 'codex', threadIds: [], suggestedAction: { @@ -2326,6 +2407,7 @@ describe('Callback Routes', () => { // user-1 mentions opus messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: '@opus from user-1', @@ -2334,6 +2416,7 @@ describe('Callback Routes', () => { }); // user-2 also mentions opus (should NOT be visible) messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-2', catId: null, content: '@opus from user-2', @@ -2360,6 +2443,7 @@ describe('Callback Routes', () => { // @opus in thread-A (should be visible) messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: '@opus in thread-A', @@ -2369,6 +2453,7 @@ describe('Callback Routes', () => { }); // @opus in thread-B (should NOT be visible — cross-thread leak) messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: '@opus in thread-B', @@ -2378,6 +2463,7 @@ describe('Callback Routes', () => { }); // @opus in thread-A again messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: '@opus in thread-A again', @@ -2418,6 +2504,7 @@ describe('Callback Routes', () => { const longContent = `@opus ${'detail '.repeat(80)}FINAL INSTRUCTION: ship it now`; messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: longContent, @@ -2453,6 +2540,7 @@ describe('Callback Routes', () => { const longContent = `@opus ${'detail '.repeat(80)}FINAL INSTRUCTION: ship it now`; messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: longContent, @@ -3027,6 +3115,7 @@ describe('Callback Routes', () => { // 10 visible messages first (OLDER timestamps: 1000-1018) for (let i = 0; i < 5; i++) { messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: `user msg ${i}`, @@ -3035,6 +3124,7 @@ describe('Callback Routes', () => { threadId: actualThreadId, }); messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'codex', content: `codex callback ${i}`, @@ -3049,6 +3139,7 @@ describe('Callback Routes', () => { // These bury the visible messages — pagination must go through all 500. for (let i = 0; i < 500; i++) { messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'codex', content: `codex stream ${i}`, @@ -3099,6 +3190,7 @@ describe('Callback Routes', () => { // 3 legacy messages from codex (no origin — pre-feature data) for (let i = 0; i < 3; i++) { messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'codex', content: `legacy codex msg ${i}`, @@ -3110,6 +3202,7 @@ describe('Callback Routes', () => { // 2 user messages for (let i = 0; i < 2; i++) { messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: `user msg ${i}`, @@ -3145,6 +3238,7 @@ describe('Callback Routes', () => { // 2 legacy untagged from codex (visible) messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'codex', content: 'legacy reply', @@ -3153,6 +3247,7 @@ describe('Callback Routes', () => { threadId: tid, }); messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'codex', content: 'legacy reply 2', @@ -3162,6 +3257,7 @@ describe('Callback Routes', () => { }); // 1 tagged stream from codex (hidden) messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'codex', content: 'thinking output', @@ -3172,6 +3268,7 @@ describe('Callback Routes', () => { }); // 1 tagged callback from codex (visible) messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'codex', content: 'callback speech', @@ -3182,6 +3279,7 @@ describe('Callback Routes', () => { }); // 1 user message (visible) messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'user question', @@ -3216,6 +3314,7 @@ describe('Callback Routes', () => { // msg1: low relevance ("redis" matches 1/2 terms) messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'redis connection pool', @@ -3225,6 +3324,7 @@ describe('Callback Routes', () => { }); // msg2: high relevance ("redis" + "lock" matches 2/2 terms) messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'redis lock contention fix', @@ -3234,6 +3334,7 @@ describe('Callback Routes', () => { }); // msg3: no match messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'deploy pipeline ready', @@ -3263,6 +3364,7 @@ describe('Callback Routes', () => { const { invocationId, callbackToken } = await registry.create('user-1', 'opus', 'thread-xyz'); messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'hi', @@ -3288,6 +3390,7 @@ describe('Callback Routes', () => { const { invocationId, callbackToken } = await registry.create('user-1', 'opus', 'thread-home'); messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'msg-A', @@ -3315,6 +3418,7 @@ describe('Callback Routes', () => { const longContent = 'Z'.repeat(2000); messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: longContent, @@ -3353,6 +3457,7 @@ describe('Callback Routes', () => { const longContent = 'Z'.repeat(2000); messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: longContent, @@ -3382,6 +3487,7 @@ describe('Callback Routes', () => { const longContent = 'Z'.repeat(2000); messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: longContent, @@ -3408,6 +3514,7 @@ describe('Callback Routes', () => { const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'Look at this image', @@ -3437,7 +3544,14 @@ describe('Callback Routes', () => { test('thread-context responseMode=full must NOT pollute Track-1 anchor savings (P1 fix)', async () => { const app = await createApp(); const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); - messageStore.append({ userId: 'user-1', catId: null, content: 'test body', mentions: [], timestamp: 1 }); + messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'user-1', + catId: null, + content: 'test body', + mentions: [], + timestamp: 1, + }); const { getAnchorTelemetrySnapshot, resetAnchorTelemetryForTest } = await import( '../dist/routes/anchor-telemetry.js' @@ -3461,7 +3575,14 @@ describe('Callback Routes', () => { test('thread-context default anchor mode records Track-1 savings normally', async () => { const app = await createApp(); const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); - messageStore.append({ userId: 'user-1', catId: null, content: 'test body', mentions: [], timestamp: 1 }); + messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'user-1', + catId: null, + content: 'test body', + mentions: [], + timestamp: 1, + }); const { getAnchorTelemetrySnapshot, resetAnchorTelemetryForTest } = await import( '../dist/routes/anchor-telemetry.js' @@ -3481,7 +3602,14 @@ describe('Callback Routes', () => { test('thread-context Track-2 event tags modeResolved/modeSource/catId (P2 adoption eval)', async () => { const app = await createApp(); const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); - messageStore.append({ userId: 'user-1', catId: null, content: 'adoption test', mentions: [], timestamp: 1 }); + messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'user-1', + catId: null, + content: 'adoption test', + mentions: [], + timestamp: 1, + }); const { getAnchorEventSnapshot, resetAnchorEventLogForTest } = await import('../dist/routes/anchor-event-log.js'); resetAnchorEventLogForTest(); @@ -3519,6 +3647,7 @@ describe('Callback Routes', () => { const app = await createApp(); const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'hey @opus', @@ -3548,6 +3677,7 @@ describe('Callback Routes', () => { const app = await createApp(); const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'hey @opus adoption test', diff --git a/packages/api/test/cat-catalog-store.test.js b/packages/api/test/cat-catalog-store.test.js index 718f520de6..f1317962bf 100644 --- a/packages/api/test/cat-catalog-store.test.js +++ b/packages/api/test/cat-catalog-store.test.js @@ -741,7 +741,9 @@ describe('cat-catalog-store', () => { assert.equal(before.opus.nickname, '宪宪'); assert.equal(before['opus-sonnet'].name, '布偶猫'); assert.equal(before['opus-sonnet'].displayName, '布偶猫'); - assert.equal(before['opus-sonnet'].nickname, '宪宪'); + // F257 #1 (dev-628ea4d1): nickname is per-cat — non-default variants no longer + // inherit the breed-level nickname (that inheritance was the collision root cause) + assert.equal(before['opus-sonnet'].nickname, undefined); await updateRuntimeCat(projectRoot, 'opus', { name: '默认布偶名', @@ -755,7 +757,9 @@ describe('cat-catalog-store', () => { assert.equal(after.opus.nickname, '默认布偶昵称'); assert.equal(after['opus-sonnet'].name, '布偶猫'); assert.equal(after['opus-sonnet'].displayName, '布偶猫'); - assert.equal(after['opus-sonnet'].nickname, '宪宪'); + // scoped-update intent unchanged: the default variant's new nickname must NOT + // leak onto the sibling variant (undefined before, still undefined after) + assert.equal(after['opus-sonnet'].nickname, undefined); const catalog = readRuntimeCatCatalog(projectRoot); const breed = catalog.breeds.find((item) => item.id === 'ragdoll'); @@ -1276,7 +1280,9 @@ describe('cat-catalog-store', () => { mcpSupport: false, cli: { command: 'codex', outputFormat: 'json' }, }); - }, /mention alias "@opus" is already used by cat "opus"/i); + // F257 #1: the cross-cat pattern check in toAllCatConfigs now fires first + // (fail-closed at the expansion choke point) and names BOTH holders. + }, /mention pattern "@opus" is shared by cats \[opus, spark-lite\]/i); const afterRaw = readFileSync(catalogPath, 'utf-8'); assert.equal(afterRaw, beforeRaw, 'failed create must not mutate runtime catalog'); diff --git a/packages/api/test/claude-ndjson-parser.test.js b/packages/api/test/claude-ndjson-parser.test.js index 644e4f4e4d..8970781459 100644 --- a/packages/api/test/claude-ndjson-parser.test.js +++ b/packages/api/test/claude-ndjson-parser.test.js @@ -510,3 +510,140 @@ test('assistant event with empty text block alongside tool_use → only tool_use assert.equal(result.length, 1, 'only tool_use, empty text filtered out'); assert.equal(result[0].type, 'tool_use'); }); + +// ─── LI-005: user → tool_result bridge ───────────────────────────────── + +test('user event with tool_result (is_error: false) → tool_result with ok status', () => { + const state = makeStreamState(); + const event = { + type: 'user', + message: { + content: [ + { + type: 'tool_result', + tool_use_id: 'toolu_abc', + content: '{"status":"ok","held":true}', + is_error: false, + }, + ], + }, + }; + const result = transformClaudeEvent(event, CAT, state); + assert.ok(result !== null, 'should not return null'); + assert.ok(Array.isArray(result), 'should return array'); + assert.equal(result.length, 1); + assert.equal(result[0].type, 'tool_result'); + assert.equal(result[0].catId, CAT); + assert.equal(result[0].content, '{"status":"ok","held":true}'); + assert.equal(result[0].toolResultStatus, 'ok'); + assert.equal(result[0].toolUseId, 'toolu_abc'); +}); + +test('user event with tool_result (is_error: true) → tool_result with error status', () => { + const state = makeStreamState(); + const event = { + type: 'user', + message: { + content: [ + { + type: 'tool_result', + tool_use_id: 'toolu_err', + content: 'Rate limit exceeded', + is_error: true, + }, + ], + }, + }; + const result = transformClaudeEvent(event, CAT, state); + assert.ok(Array.isArray(result)); + assert.equal(result.length, 1); + assert.equal(result[0].type, 'tool_result'); + assert.equal(result[0].toolResultStatus, 'error'); + assert.equal(result[0].content, 'Rate limit exceeded'); +}); + +test('user event with array content blocks → extracts text from [{type:"text",text:"..."}]', () => { + const state = makeStreamState(); + const event = { + type: 'user', + message: { + content: [ + { + type: 'tool_result', + tool_use_id: 'toolu_arr', + content: [ + { type: 'text', text: '{"status":' }, + { type: 'text', text: '"ok"}' }, + ], + is_error: false, + }, + ], + }, + }; + const result = transformClaudeEvent(event, CAT, state); + assert.ok(Array.isArray(result)); + assert.equal(result[0].content, '{"status":"ok"}'); + assert.equal(result[0].toolResultStatus, 'ok'); +}); + +test('user event with multiple tool_result blocks → returns array of all', () => { + const state = makeStreamState(); + const event = { + type: 'user', + message: { + content: [ + { type: 'tool_result', tool_use_id: 'toolu_1', content: '{"a":1}', is_error: false }, + { type: 'tool_result', tool_use_id: 'toolu_2', content: '{"b":2}', is_error: true }, + ], + }, + }; + const result = transformClaudeEvent(event, CAT, state); + assert.ok(Array.isArray(result)); + assert.equal(result.length, 2); + assert.equal(result[0].toolResultStatus, 'ok'); + assert.equal(result[1].toolResultStatus, 'error'); +}); + +test('user event without content array → null', () => { + const state = makeStreamState(); + const result = transformClaudeEvent({ type: 'user', message: {} }, CAT, state); + assert.equal(result, null); +}); + +test('user event with no tool_result blocks → null', () => { + const state = makeStreamState(); + const event = { + type: 'user', + message: { content: [{ type: 'text', text: 'hello' }] }, + }; + const result = transformClaudeEvent(event, CAT, state); + assert.equal(result, null); +}); + +test('user event tool_result without tool_use_id → no toolUseId on message', () => { + const state = makeStreamState(); + const event = { + type: 'user', + message: { + content: [{ type: 'tool_result', content: 'data', is_error: false }], + }, + }; + const result = transformClaudeEvent(event, CAT, state); + assert.ok(Array.isArray(result)); + assert.equal(result[0].toolUseId, undefined); + assert.equal(result[0].toolResultStatus, 'ok'); +}); + +test('user event tool_result with undefined content → content is undefined', () => { + const state = makeStreamState(); + const event = { + type: 'user', + message: { + content: [{ type: 'tool_result', tool_use_id: 'toolu_nc', is_error: false }], + }, + }; + const result = transformClaudeEvent(event, CAT, state); + assert.ok(Array.isArray(result)); + assert.equal(result[0].content, undefined); + assert.equal(result[0].toolResultStatus, 'ok'); +}); diff --git a/packages/api/test/commands-route.test.js b/packages/api/test/commands-route.test.js index f00585b0c0..c609f31fbb 100644 --- a/packages/api/test/commands-route.test.js +++ b/packages/api/test/commands-route.test.js @@ -60,6 +60,7 @@ describe('Commands Routes', () => { it('POST /api/commands/extract-tasks creates tasks', async () => { // Add some messages first await messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, content: 'TODO: write tests', userId: 'test-user', threadId: ownThreadId, @@ -110,6 +111,7 @@ describe('Commands Routes', () => { it('uses X-Cat-Cafe-User header over legacy payload userId', async () => { await messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, content: 'TODO: header identity should win', userId: 'test-user', threadId: ownThreadId, @@ -145,6 +147,7 @@ describe('Commands Routes', () => { it('returns 403 when accessing another user thread', async () => { await messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, content: 'TODO: should not be visible', userId: 'other-user', threadId: otherThreadId, diff --git a/packages/api/test/concierge-a3b-route.test.js b/packages/api/test/concierge-a3b-route.test.js index a274a83dae..ff7878eef5 100644 --- a/packages/api/test/concierge-a3b-route.test.js +++ b/packages/api/test/concierge-a3b-route.test.js @@ -479,6 +479,7 @@ describe('GET /api/concierge/peek', () => { // Seed messages in a thread — append returns StoredMessage with generated id for (let i = 0; i < 7; i++) { const stored = messageStore.append({ + provenance: { author: i % 2 === 0 ? 'user' : 'cat', routed: false, observation: 'original' }, threadId: 'peek-thread', content: `Message ${i}`, userId: 'test-user', diff --git a/packages/api/test/concurrent-fault-drill.test.js b/packages/api/test/concurrent-fault-drill.test.js index badc44f85a..9cf33afc71 100644 --- a/packages/api/test/concurrent-fault-drill.test.js +++ b/packages/api/test/concurrent-fault-drill.test.js @@ -78,6 +78,7 @@ describe('Concurrent fault drills - in-memory stores', () => { const baseTs = Date.now(); const base = messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId, catId: null, content: 'base', @@ -89,6 +90,7 @@ describe('Concurrent fault drills - in-memory stores', () => { const appendPromise = Promise.resolve().then(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); return messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId, catId: null, content: 'new-after-ack', @@ -248,6 +250,7 @@ describe('Concurrent fault drills - Redis stores', { skip: redisIsolationSkipRea const baseTs = Date.now(); const base = await messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId, catId: null, content: 'base', @@ -259,6 +262,7 @@ describe('Concurrent fault drills - Redis stores', { skip: redisIsolationSkipRea const appendPromise = Promise.resolve().then(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); return messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId, catId: null, content: 'new-after-ack', diff --git a/packages/api/test/connector-invoke-trigger.test.js b/packages/api/test/connector-invoke-trigger.test.js index aec2fef2eb..4b894c29a6 100644 --- a/packages/api/test/connector-invoke-trigger.test.js +++ b/packages/api/test/connector-invoke-trigger.test.js @@ -258,6 +258,35 @@ describe('ConnectorInvokeTrigger', () => { assert.deepStrictEqual(routerMock.calls[0].targetCats, ['opus']); }); + it('F257 LI-001: direct dispatch forwards completionRequirement to routeExecution', async () => { + const trigger = createTrigger(); + await trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'wake', 'msg-live-direct', undefined, { + sourceCategory: 'scheduled', + completionRequirement: 'action-or-routing-exit', + }); + await waitForTrigger(); + + assert.strictEqual(routerMock.calls.length, 1); + assert.strictEqual(routerMock.calls[0].options?.completionRequirement, 'action-or-routing-exit'); + }); + + it('F257 LI-001: busy dispatch persists completionRequirement on QueueEntry', async () => { + trackerMock.setActive('thread-1', 'user-1'); + const trigger = createTrigger(); + const outcome = await trigger.trigger( + 'thread-1', + /** @type {any} */ ('opus'), + 'user-1', + 'wake', + 'msg-live-queued', + undefined, + { sourceCategory: 'scheduled', completionRequirement: 'action-or-routing-exit' }, + ); + + assert.strictEqual(outcome, 'enqueued'); + assert.strictEqual(queue.list('thread-1', 'user-1')[0]?.completionRequirement, 'action-or-routing-exit'); + }); + it('F222 P1: connector direct routeExecution passes frustrationAutoIssueEligible=false', async () => { const trigger = createTrigger(); trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'Review msg', 'msg-f222'); diff --git a/packages/api/test/connector-router.test.js b/packages/api/test/connector-router.test.js index 65e459896d..bce0c97d94 100644 --- a/packages/api/test/connector-router.test.js +++ b/packages/api/test/connector-router.test.js @@ -199,6 +199,11 @@ describe('ConnectorRouter', () => { assert.equal(messageStore.messages[0].source.label, '飞书'); assert.equal(typeof messageStore.messages[0].source.icon, 'string'); assert.equal(messageStore.messages[0].source.icon, '/images/connectors/feishu.png'); + assert.equal( + messageStore.messages[0].provenance.author, + 'external_user', + 'connector sender is human but not the authenticated local operator', + ); }); it('triggers cat invocation', async () => { diff --git a/packages/api/test/cursor-deferred-ack.test.js b/packages/api/test/cursor-deferred-ack.test.js index e59a7a7170..ad05fd1226 100644 --- a/packages/api/test/cursor-deferred-ack.test.js +++ b/packages/api/test/cursor-deferred-ack.test.js @@ -32,6 +32,13 @@ function createTrackingRouter(options = {}) { yield { type: 'text', catId: 'opus', content: 'ok', timestamp: Date.now() }; }, resolveTargetsAndIntent: async () => ({ + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, targetCats: ['opus'], intent: { intent: 'execute', explicit: false, promptTags: [] }, }), @@ -51,6 +58,7 @@ async function setupScenario(router, status = 'failed') { const socketManager = createMockSocketManager(); const storedMsg = messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: '@布偶猫 cursor test', @@ -140,6 +148,13 @@ describe('ADR-008 S3: cursor deferred ack', () => { yield { type: 'text', catId: 'opus', content: 'cap', timestamp: Date.now() }; }, resolveTargetsAndIntent: async () => ({ + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, targetCats: ['opus'], intent: { intent: 'execute', explicit: false, promptTags: [] }, }), diff --git a/packages/api/test/delivery-status.test.js b/packages/api/test/delivery-status.test.js index 956c48c143..c958d020ee 100644 --- a/packages/api/test/delivery-status.test.js +++ b/packages/api/test/delivery-status.test.js @@ -32,6 +32,7 @@ describe('F117: deliveryStatus + isDelivered', () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); const msg = store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'queued msg', @@ -53,6 +54,7 @@ describe('F117: deliveryStatus + isDelivered', () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); const msg = store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'queued msg', @@ -70,6 +72,7 @@ describe('F117: deliveryStatus + isDelivered', () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); const msg = store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'immediate msg', @@ -92,6 +95,7 @@ describe('F117: deliveryStatus + isDelivered', () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); const msg = store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'already delivered', @@ -121,9 +125,17 @@ describe('F117: getByThread filters undelivered messages', () => { const now = Date.now(); // legacy message (no deliveryStatus) — should appear - store.append({ userId: 'u1', catId: null, content: 'legacy', mentions: [], timestamp: now }); + store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'u1', + catId: null, + content: 'legacy', + mentions: [], + timestamp: now, + }); // delivered message — should appear const delivered = store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u1', catId: null, content: 'delivered', @@ -134,6 +146,7 @@ describe('F117: getByThread filters undelivered messages', () => { store.markDelivered(delivered.id, now + 1); // queued message — should NOT appear store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u1', catId: null, content: 'queued', @@ -143,6 +156,7 @@ describe('F117: getByThread filters undelivered messages', () => { }); // canceled message — should NOT appear const canceled = store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u1', catId: null, content: 'canceled', @@ -169,6 +183,7 @@ describe('F117: getByThreadAfter filters undelivered messages', () => { const now = Date.now(); const m1 = store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u1', catId: null, content: 'delivered', @@ -178,6 +193,7 @@ describe('F117: getByThreadAfter filters undelivered messages', () => { }); store.markDelivered(m1.id, now); store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u1', catId: null, content: 'queued', @@ -186,6 +202,7 @@ describe('F117: getByThreadAfter filters undelivered messages', () => { deliveryStatus: 'queued', }); const canceled = store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u1', catId: null, content: 'canceled', @@ -195,6 +212,7 @@ describe('F117: getByThreadAfter filters undelivered messages', () => { }); store.markCanceled(canceled.id); store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u1', catId: null, content: 'legacy', @@ -277,6 +295,7 @@ describe('F117: getMentionsFor filters undelivered messages', () => { // delivered mention — should appear const delivered = store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u1', catId: null, content: '@gpt52 delivered', @@ -287,6 +306,7 @@ describe('F117: getMentionsFor filters undelivered messages', () => { store.markDelivered(delivered.id, now); // queued mention — should NOT appear store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u1', catId: null, content: '@gpt52 queued', @@ -296,6 +316,7 @@ describe('F117: getMentionsFor filters undelivered messages', () => { }); // canceled mention — should NOT appear const canceled = store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u1', catId: null, content: '@gpt52 canceled', @@ -305,7 +326,14 @@ describe('F117: getMentionsFor filters undelivered messages', () => { }); store.markCanceled(canceled.id); // legacy mention (no deliveryStatus) — should appear - store.append({ userId: 'u1', catId: null, content: '@gpt52 legacy', mentions: ['gpt52'], timestamp: now + 3 }); + store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'u1', + catId: null, + content: '@gpt52 legacy', + mentions: ['gpt52'], + timestamp: now + 3, + }); const mentions = store.getMentionsFor('gpt52', 50, 'u1'); const contents = mentions.map((m) => m.content); @@ -321,6 +349,7 @@ describe('F117: getMentionsFor filters undelivered messages', () => { const now = Date.now(); const delivered = store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u1', catId: null, content: '@gpt52 delivered', @@ -330,6 +359,7 @@ describe('F117: getMentionsFor filters undelivered messages', () => { }); store.markDelivered(delivered.id, now); store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u1', catId: null, content: '@gpt52 queued', @@ -352,6 +382,7 @@ describe('F117: messages_delivered payload includes message data', () => { const now = Date.now(); const msg = store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u1', catId: null, content: 'hello cat', @@ -385,6 +416,7 @@ describe('F117: integration regression', () => { // Simulate queue send const queuedMsg = store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u1', catId: null, content: '@gpt52 嘿嘿大猫猫喵', diff --git a/packages/api/test/deviation-event-log.test.js b/packages/api/test/deviation-event-log.test.js new file mode 100644 index 0000000000..ad51eea51b --- /dev/null +++ b/packages/api/test/deviation-event-log.test.js @@ -0,0 +1,303 @@ +/** + * F257 V1 — DeviationEventLog tests. + * + * Semantics single source of truth: F257 redesign doc §3.1 (schema union + + * DeviationEventLog 存储规格) + T-C §3.6 (incidentKey / 幂等 / Lua 原子). + * 有 Redis → 测全量;无 Redis → 只跑纯函数 describe(与 projection 测试同模式)。 + */ + +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { after, before, beforeEach, describe, it } from 'node:test'; +import { + assertRedisIsolationOrThrow, + cleanupPrefixedRedisKeys, + redisIsolationSkipReason, +} from './helpers/redis-test-helpers.js'; + +const REDIS_URL = process.env.REDIS_URL; +const OWNER = 'owner-f257-dev'; + +const model = await import('../dist/infrastructure/harness-eval/deviation/deviation-event.js'); +const { manualIncidentKey, conditionIncidentKey, validateDeviationEvent, V1_REGISTRY_VERSION } = model; + +const anchorA = { kind: 'thread_message', messageId: 'msg-a' }; + +function attribution(overrides = {}) { + return { + objectiveId: 'obj-routing-delivery', + unitRefs: [{ unitType: 'segment', unitId: 'S1' }], + weight: 0.8, + ...overrides, + }; +} + +function manualEvent(overrides = {}) { + const attributions = overrides.attributions ?? [attribution()]; + const sourceAnchor = overrides.sourceAnchor ?? anchorA; + const subjectCatId = overrides.subjectCatId ?? 'cat-subject'; + const ownerUserId = overrides.ownerUserId ?? OWNER; + return { + kind: 'manual_observation', + eventId: `dev-${randomUUID()}`, + timestamp: Date.now(), + registryVersion: V1_REGISTRY_VERSION, + incidentKey: manualIncidentKey(ownerUserId, sourceAnchor, subjectCatId, attributions), + ownerUserId, + attributions, + anchors: { threadId: 'th-dev', messageId: sourceAnchor.messageId }, + source: 'peer', + subjectCatId, + note: 'observed drift in relay handoff', + sourceAnchor, + recordedBy: 'cat-recorder', + ...overrides, + }; +} + +function conditionEvent(overrides = {}) { + const ownerUserId = overrides.ownerUserId ?? OWNER; + return { + kind: 'condition_hit', + eventId: `dev-${randomUUID()}`, + timestamp: Date.now(), + registryVersion: 'cond-registry-v0', + incidentKey: conditionIncidentKey(ownerUserId, 'signature_missing', 'fact:msg-1'), + ownerUserId, + attributions: [attribution({ weight: 1.0 })], + anchors: { threadId: 'th-dev', messageId: 'msg-1' }, + conditionId: 'signature_missing', + sourceFactRef: 'fact:msg-1', + recordedBy: 'system', + subjectCatId: 'cat-subject', + ...overrides, + }; +} + +describe('F257 V1: deviation-event pure model (T-C incidentKey + §3.1 validation)', () => { + it('manualIncidentKey is stable across attribution/unitRef ordering (服务端排序防换序绕过)', () => { + const attrs = [ + attribution({ objectiveId: 'obj-b', unitRefs: [{ unitType: 'segment', unitId: 'S2' }] }), + attribution({ + objectiveId: 'obj-a', + unitRefs: [ + { unitType: 'segment', unitId: 'S9' }, + { unitType: 'segment', unitId: 'S1' }, + ], + }), + ]; + const swapped = [{ ...attrs[1], unitRefs: [...attrs[1].unitRefs].reverse() }, attrs[0]]; + assert.equal( + manualIncidentKey(OWNER, anchorA, 'cat-s', attrs), + manualIncidentKey(OWNER, anchorA, 'cat-s', swapped), + ); + }); + + it('manualIncidentKey: weight is NOT identity; unitId/owner/anchor/subject ARE (T-C v2.3)', () => { + const base = manualIncidentKey(OWNER, anchorA, 'cat-s', [attribution({ weight: 0.3 })]); + assert.equal(base, manualIncidentKey(OWNER, anchorA, 'cat-s', [attribution({ weight: 0.9 })])); + assert.notEqual( + base, + manualIncidentKey(OWNER, anchorA, 'cat-s', [attribution({ unitRefs: [{ unitType: 'segment', unitId: 'D1' }] })]), + ); + assert.notEqual(base, manualIncidentKey('owner-other', anchorA, 'cat-s', [attribution()])); + assert.notEqual( + base, + manualIncidentKey(OWNER, { kind: 'thread_message', messageId: 'msg-b' }, 'cat-s', [attribution()]), + ); + assert.notEqual(base, manualIncidentKey(OWNER, anchorA, 'cat-other', [attribution()])); + }); + + it('conditionIncidentKey is owner-namespaced (§3.1 v1.8)', () => { + assert.notEqual( + conditionIncidentKey(OWNER, 'cond-1', 'fact:1'), + conditionIncidentKey('owner-other', 'cond-1', 'fact:1'), + ); + }); + + it('validateDeviationEvent: manual weights ∈ (0,1], objective 不重复, unitType V1 仅 segment', () => { + assert.deepEqual(validateDeviationEvent(manualEvent()), []); + assert.ok(validateDeviationEvent(manualEvent({ attributions: [attribution({ weight: 0 })] })).length > 0); + assert.ok(validateDeviationEvent(manualEvent({ attributions: [attribution({ weight: 1.2 })] })).length > 0); + assert.ok( + validateDeviationEvent(manualEvent({ attributions: [attribution(), attribution({ weight: 0.4 })] })).length > 0, + 'duplicate objectiveId must be rejected', + ); + assert.ok( + validateDeviationEvent( + manualEvent({ attributions: [attribution({ unitRefs: [{ unitType: 'skill', unitId: 'k1' }] })] }), + ).length > 0, + 'unitType outside V1 adapter registry must be rejected', + ); + assert.ok(validateDeviationEvent(manualEvent({ attributions: [] })).length > 0); + assert.ok(validateDeviationEvent(manualEvent({ attributions: [attribution({ unitRefs: [] })] })).length > 0); + assert.ok(validateDeviationEvent(manualEvent({ note: '' })).length > 0); + assert.ok(validateDeviationEvent(manualEvent({ subjectCatId: '' })).length > 0); + }); + + it('validateDeviationEvent: exact 支强制单条 weight=1.0 (§3.1)', () => { + assert.deepEqual(validateDeviationEvent(conditionEvent()), []); + assert.ok(validateDeviationEvent(conditionEvent({ attributions: [attribution({ weight: 0.9 })] })).length > 0); + assert.ok( + validateDeviationEvent( + conditionEvent({ + attributions: [attribution({ weight: 1.0 }), attribution({ objectiveId: 'obj-x', weight: 1.0 })], + }), + ).length > 0, + 'exact branch must have exactly one attribution', + ); + assert.ok( + validateDeviationEvent(conditionEvent({ recordedBy: 'cat-x' })).length > 0, + 'condition_hit recordedBy must be system', + ); + }); +}); + +describe( + 'F257 V1: RedisDeviationEventLog (§3.1 存储规格 + T-C Lua 原子)', + { skip: redisIsolationSkipReason(REDIS_URL) }, + () => { + let log; + let DeviationKeys; + let redis; + let connected = false; + // owner-scoped cleanup —— 两个 deviation 测试文件可并发跑,不互删数据 + const CLEANUP_PATTERNS = [`deviation:*:${OWNER}`, 'deviation:*:owner-other']; + + before(async () => { + assertRedisIsolationOrThrow(REDIS_URL, 'RedisDeviationEventLog'); + const mod = await import('../dist/infrastructure/harness-eval/deviation/DeviationEventLog.js'); + const redisModule = await import('@cat-cafe/shared/utils'); + redis = redisModule.createRedisClient({ url: REDIS_URL }); + try { + await redis.ping(); + connected = true; + } catch { + await redis.quit().catch(() => {}); + return; + } + log = new mod.RedisDeviationEventLog(redis); + DeviationKeys = mod.DeviationKeys; + }); + + after(async () => { + if (redis && connected) { + await cleanupPrefixedRedisKeys(redis, CLEANUP_PATTERNS); + await redis.quit(); + } + }); + + beforeEach(async (t) => { + if (!connected) return t.skip('Redis not connected'); + await cleanupPrefixedRedisKeys(redis, CLEANUP_PATTERNS); + }); + + it('append → query roundtrip; TTL=0 on every key (存储规格 / 铁律#5)', async () => { + const evt = manualEvent(); + const res = await log.append(evt); + assert.deepEqual(res, { outcome: 'appended', eventId: evt.eventId }); + + const q = await log.query({ ownerUserId: OWNER }); + assert.equal(q.events.length, 1); + assert.deepEqual(q.events[0], evt); + assert.equal(q.nextCursor, null); + assert.deepEqual(q.missingBodies, []); + + // pttl: -1 = key 存在且无 TTL;-2 = 不存在(一并断言存在性) + for (const key of [DeviationKeys.events(OWNER), DeviationKeys.index(OWNER), DeviationKeys.claims(OWNER)]) { + assert.equal(await redis.pttl(key), -1, `key ${key} must exist with no TTL`); + } + }); + + it('same incidentKey → incident_claimed, ledger unchanged (T-C 原子 claim)', async () => { + const first = manualEvent(); + assert.equal((await log.append(first)).outcome, 'appended'); + // 同 incident 重报:新 eventId、weight 变化都不绕过 claim(weight 不在 identity 里) + const dup = manualEvent({ attributions: [attribution({ weight: 0.2 })] }); + const res = await log.append(dup); + assert.deepEqual(res, { outcome: 'incident_claimed', eventId: first.eventId }); + assert.equal(await log.countInWindow(OWNER, 0, Date.now() + 1000), 1); + }); + + it('different unitRef → different incident, both land (T-C v2.3 canonical attributions)', async () => { + assert.equal((await log.append(manualEvent())).outcome, 'appended'); + const other = manualEvent({ + attributions: [attribution({ unitRefs: [{ unitType: 'segment', unitId: 'D1' }] })], + }); + assert.equal((await log.append(other)).outcome, 'appended'); + assert.equal(await log.countInWindow(OWNER, 0, Date.now() + 1000), 2); + }); + + it('idempotencyKey replay returns original eventId without double append (T-C 幂等)', async () => { + const evt = manualEvent(); + const first = await log.append(evt, { idempotencyKey: 'cat-recorder:th-dev:retry-1' }); + assert.equal(first.outcome, 'appended'); + const retry = await log.append(manualEvent(), { idempotencyKey: 'cat-recorder:th-dev:retry-1' }); + assert.deepEqual(retry, { outcome: 'idempotent_replay', eventId: evt.eventId }); + assert.equal(await log.countInWindow(OWNER, 0, Date.now() + 1000), 1); + assert.equal(await redis.pttl(DeviationKeys.idempotency(OWNER)), -1, 'idem key must exist with no TTL'); + }); + + it('invalid event throws (await-append §4.5-2 — 写失败显式可见,不 fail-open)', async () => { + await assert.rejects(() => log.append(manualEvent({ attributions: [attribution({ weight: 0 })] })), /weight/); + assert.equal(await log.countInWindow(OWNER, 0, Date.now() + 1000), 0); + }); + + it('condition_hit branch is storable (union support; V1 只是无 writer)', async () => { + const evt = conditionEvent(); + assert.equal((await log.append(evt)).outcome, 'appended'); + const q = await log.query({ ownerUserId: OWNER }); + assert.deepEqual(q.events[0], evt); + }); + + it('pagination: cursor walk covers all events, no dup/loss, incl. same-timestamp ties (不沿用 200 静默截断)', async () => { + const base = Date.now(); + const ids = []; + for (let i = 0; i < 25; i += 1) { + // 前 8 条共享同一 timestamp,逼出 cursor tie-break 路径 + const ts = i < 8 ? base : base + i; + const evt = manualEvent({ + timestamp: ts, + sourceAnchor: { kind: 'thread_message', messageId: `msg-${i}` }, + anchors: { threadId: 'th-dev', messageId: `msg-${i}` }, + }); + ids.push(evt.eventId); + assert.equal((await log.append(evt)).outcome, 'appended'); + } + + const seen = []; + let cursor; + for (let page = 0; page < 10; page += 1) { + const q = await log.query({ ownerUserId: OWNER, limit: 10, ...(cursor ? { cursor } : {}) }); + seen.push(...q.events.map((e) => e.eventId)); + if (!q.nextCursor) break; + cursor = q.nextCursor; + } + assert.equal(seen.length, 25); + assert.equal(new Set(seen).size, 25); + assert.deepEqual(new Set(seen), new Set(ids)); + }); + + it('query window filter + countInWindow agree (完整聚合口径)', async () => { + const base = Date.now(); + for (let i = 0; i < 6; i += 1) { + await log.append( + manualEvent({ + timestamp: base + i * 100, + sourceAnchor: { kind: 'thread_message', messageId: `msg-w${i}` }, + }), + ); + } + const q = await log.query({ ownerUserId: OWNER, fromMs: base + 100, toMs: base + 400 }); + assert.equal(q.events.length, 4); + assert.equal(await log.countInWindow(OWNER, base + 100, base + 400), 4); + }); + + it('owner isolation: owner B sees nothing of owner A (ownerUserId 进索引与查询授权)', async () => { + await log.append(manualEvent()); + const q = await log.query({ ownerUserId: 'owner-other' }); + assert.equal(q.events.length, 0); + assert.equal(await log.countInWindow('owner-other', 0, Date.now() + 1000), 0); + }); + }, +); diff --git a/packages/api/test/draft-messages-merge.test.js b/packages/api/test/draft-messages-merge.test.js index b2ee69353b..5b619b3bbd 100644 --- a/packages/api/test/draft-messages-merge.test.js +++ b/packages/api/test/draft-messages-merge.test.js @@ -20,6 +20,13 @@ import { messagesRoutes } from '../dist/routes/messages.js'; function makeStubRouter() { return { resolveTargetsAndIntent: async () => ({ + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, targetCats: ['opus'], intent: { intent: 'execute', promptTags: [], targets: ['opus'] }, }), @@ -146,6 +153,7 @@ describe('GET /api/messages — draft merge (#80)', () => { it('includes active drafts on first page (no cursor)', async () => { // Seed a formal message messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'Hello', @@ -188,6 +196,7 @@ describe('GET /api/messages — draft merge (#80)', () => { // Seed messages const ts = Date.now(); messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'First', @@ -224,6 +233,7 @@ describe('GET /api/messages — draft merge (#80)', () => { // Formal message with invocationId in extra.stream messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'opus', content: 'Completed message', @@ -593,6 +603,7 @@ describe('GET /api/messages — draft merge (#80)', () => { // Seed a message so user-A gets non-empty response messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-A', catId: null, content: 'Hi', @@ -619,6 +630,7 @@ describe('GET /api/messages — draft merge (#80)', () => { // 1. Seed the formal message with invocationId (oldest — will be pushed off page) messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'opus', content: 'Completed streaming response', @@ -632,6 +644,7 @@ describe('GET /api/messages — draft merge (#80)', () => { // Using limit=5 via query param, so we need 5 newer messages for (let i = 1; i <= 5; i++) { messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: `Filler message ${i}`, @@ -678,6 +691,7 @@ describe('GET /api/messages — draft merge (#80)', () => { // 1. Seed the formal message (will be the 201st oldest → pushed off a 200-message page) messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'opus', content: 'Completed at max-limit edge', @@ -690,6 +704,7 @@ describe('GET /api/messages — draft merge (#80)', () => { // 2. Seed 200 newer messages to push formal off the first page at limit=200 for (let i = 1; i <= 200; i++) { messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: `Filler ${i}`, @@ -729,6 +744,7 @@ describe('GET /api/messages — draft merge (#80)', () => { // Seed a user message so the thread has content messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'Do something', @@ -770,6 +786,7 @@ describe('GET /api/messages — draft merge (#80)', () => { const ts = Date.now(); messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'Hello', @@ -837,6 +854,7 @@ describe('GET /api/messages — draft merge (#80)', () => { // Seed a formal message to have a non-empty page messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'Question', diff --git a/packages/api/test/duty-briefing-e2e-redis.test.js b/packages/api/test/duty-briefing-e2e-redis.test.js index f7db0fcf4e..152be4bdc0 100644 --- a/packages/api/test/duty-briefing-e2e-redis.test.js +++ b/packages/api/test/duty-briefing-e2e-redis.test.js @@ -90,6 +90,7 @@ describe( for (let i = 0; i < 55; i += 1) { await messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, threadId, userId: 'default-user', catId: null, diff --git a/packages/api/test/f148-assemble-incremental.test.js b/packages/api/test/f148-assemble-incremental.test.js index 99387ad6bc..9acb7de243 100644 --- a/packages/api/test/f148-assemble-incremental.test.js +++ b/packages/api/test/f148-assemble-incremental.test.js @@ -13,6 +13,11 @@ function mockMsg(overrides) { threadId: overrides.threadId ?? 'thread-1', userId: overrides.userId ?? 'user-1', catId: overrides.catId ?? null, + provenance: overrides.provenance ?? { + author: overrides.catId ? 'cat' : 'user', + routed: false, + observation: 'original', + }, content: overrides.content ?? 'test message', mentions: overrides.mentions ?? [], timestamp: ts, diff --git a/packages/api/test/f194-canonical-liveness-routes.test.js b/packages/api/test/f194-canonical-liveness-routes.test.js index b97c5341d3..4da672c1a9 100644 --- a/packages/api/test/f194-canonical-liveness-routes.test.js +++ b/packages/api/test/f194-canonical-liveness-routes.test.js @@ -29,6 +29,13 @@ const USER_ID = 'user-1'; function makeStubRouter() { return { resolveTargetsAndIntent: async () => ({ + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, targetCats: ['opus'], intent: { intent: 'execute', promptTags: [], targets: ['opus'] }, }), diff --git a/packages/api/test/f194-phase-z-routes-integration.test.js b/packages/api/test/f194-phase-z-routes-integration.test.js index 736c45b113..9e7d21d8b7 100644 --- a/packages/api/test/f194-phase-z-routes-integration.test.js +++ b/packages/api/test/f194-phase-z-routes-integration.test.js @@ -36,6 +36,13 @@ const USER_ID = 'user-z'; function makeStubRouter() { return { resolveTargetsAndIntent: async () => ({ + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, targetCats: ['opus'], intent: { intent: 'execute', promptTags: [], targets: ['opus'] }, }), diff --git a/packages/api/test/f230-hook-setup.test.js b/packages/api/test/f230-hook-setup.test.js index 5b30a277de..4d238e6770 100644 --- a/packages/api/test/f230-hook-setup.test.js +++ b/packages/api/test/f230-hook-setup.test.js @@ -21,7 +21,7 @@ function makeTmpCwd() { // setupHookInfrastructure — settings.json creation // --------------------------------------------------------------------------- -test('hook setup: creates .claude/settings.json with Stop + PostToolUse hooks', async () => { +test('hook setup: creates .claude/settings.json with Stop + PostToolUse + PostToolUseFailure hooks', async () => { const tmpCwd = makeTmpCwd(); const sidecarPath = join(tmpCwd, 'sidecar.jsonl'); const result = await setupHookInfrastructure(tmpCwd, sidecarPath); @@ -46,6 +46,13 @@ test('hook setup: creates .claude/settings.json with Stop + PostToolUse hooks', settings.hooks.PostToolUse[0].hooks[0].command.includes(result.scriptPath), 'PostToolUse hook must point to capture script', ); + // LI-005: PostToolUseFailure must be registered for failure path bridging + assert.ok(settings.hooks.PostToolUseFailure, 'PostToolUseFailure hook must be configured'); + assert.ok(Array.isArray(settings.hooks.PostToolUseFailure), 'PostToolUseFailure must be array'); + assert.ok( + settings.hooks.PostToolUseFailure[0].hooks[0].command.includes(result.scriptPath), + 'PostToolUseFailure hook must point to capture script', + ); } finally { await result.cleanup(); } diff --git a/packages/api/test/f230-hook-sidechannel-consumer.test.js b/packages/api/test/f230-hook-sidechannel-consumer.test.js index fe02d93247..150019354d 100644 --- a/packages/api/test/f230-hook-sidechannel-consumer.test.js +++ b/packages/api/test/f230-hook-sidechannel-consumer.test.js @@ -64,7 +64,7 @@ test('hook consumer: Stop event without last_assistant_message field → skipped // hookEntriesToAgentMessages — PostToolUse event // --------------------------------------------------------------------------- -test('hook consumer: PostToolUse event → tool_use AgentMessage', () => { +test('hook consumer: PostToolUse event → tool_use + tool_result AgentMessages', () => { const entries = [ { hook_event_name: 'PostToolUse', @@ -77,12 +77,17 @@ test('hook consumer: PostToolUse event → tool_use AgentMessage', () => { }, ]; const msgs = hookEntriesToAgentMessages(entries, { catId: 'opus' }); - assert.equal(msgs.length, 1); + assert.equal(msgs.length, 2, 'PostToolUse emits tool_use + tool_result'); assert.equal(msgs[0].type, 'tool_use'); assert.equal(msgs[0].toolName, 'Read'); assert.deepEqual(msgs[0].toolInput, { file_path: '/foo/bar.ts' }); assert.equal(msgs[0].toolUseId, 'tu_001'); assert.equal(msgs[0].catId, 'opus'); + // LI-005: tool_result companion — PostToolUse = success event + assert.equal(msgs[1].type, 'tool_result'); + assert.equal(msgs[1].content, 'file contents here'); + assert.equal(msgs[1].toolUseId, 'tu_001'); + assert.equal(msgs[1].toolResultStatus, 'ok', 'PostToolUse = success → ok'); }); test('hook consumer: PostToolUse with missing tool_name → skipped', () => { @@ -105,7 +110,7 @@ test('hook consumer: PostToolUse with missing tool_name → skipped', () => { // hookEntriesToAgentMessages — mixed events // --------------------------------------------------------------------------- -test('hook consumer: mixed PostToolUse + Stop → correct order', () => { +test('hook consumer: mixed PostToolUse + Stop → correct order (use/result pairs)', () => { const entries = [ { hook_event_name: 'PostToolUse', @@ -132,13 +137,20 @@ test('hook consumer: mixed PostToolUse + Stop → correct order', () => { }, ]; const msgs = hookEntriesToAgentMessages(entries, { catId: 'opus' }); - assert.equal(msgs.length, 3); + // 2 PostToolUse × (tool_use + tool_result) + 1 Stop(text) = 5 + assert.equal(msgs.length, 5); assert.equal(msgs[0].type, 'tool_use'); assert.equal(msgs[0].toolName, 'Bash'); - assert.equal(msgs[1].type, 'tool_use'); - assert.equal(msgs[1].toolName, 'Read'); - assert.equal(msgs[2].type, 'text'); - assert.equal(msgs[2].content, 'Done!'); + assert.equal(msgs[1].type, 'tool_result'); + assert.equal(msgs[1].content, 'file1\nfile2'); + assert.equal(msgs[1].toolResultStatus, 'ok'); + assert.equal(msgs[2].type, 'tool_use'); + assert.equal(msgs[2].toolName, 'Read'); + assert.equal(msgs[3].type, 'tool_result'); + assert.equal(msgs[3].content, 'contents'); + assert.equal(msgs[3].toolResultStatus, 'ok'); + assert.equal(msgs[4].type, 'text'); + assert.equal(msgs[4].content, 'Done!'); }); test('hook consumer: unknown event type → skipped', () => { @@ -233,3 +245,111 @@ test('hook consumer: extractEntrypointFromHookEntries — non-string → undefin const entries = [{ hook_event_name: 'Stop', session_id: 'abc', _cc_entrypoint: 42 }]; assert.equal(extractEntrypointFromHookEntries(entries), undefined); }); + +// --------------------------------------------------------------------------- +// LI-005: PostToolUse → tool_result bridge (durable trigger classification) +// --------------------------------------------------------------------------- + +test('LI-005: PostToolUse with string tool_response → content string, status ok', () => { + const entries = [ + { + hook_event_name: 'PostToolUse', + tool_name: 'cat_cafe_hold_ball', + tool_response: '{"status":"ok","held":true}', + tool_use_id: 'tu_hold', + }, + ]; + const msgs = hookEntriesToAgentMessages(entries, { catId: 'opus' }); + const result = msgs.find((m) => m.type === 'tool_result'); + assert.ok(result, 'tool_result must be emitted'); + assert.equal(result.toolResultStatus, 'ok', 'PostToolUse = success event'); + assert.equal(result.content, '{"status":"ok","held":true}'); +}); + +test('LI-005: PostToolUse with structured object tool_response → JSON.stringify', () => { + const entries = [ + { + hook_event_name: 'PostToolUse', + tool_name: 'Read', + tool_response: { type: 'text', file: { content: 'code', totalLines: 50 } }, + tool_use_id: 'tu_read', + }, + ]; + const msgs = hookEntriesToAgentMessages(entries, { catId: 'opus' }); + const result = msgs.find((m) => m.type === 'tool_result'); + assert.ok(result); + assert.equal(result.toolResultStatus, 'ok'); + // Structured response normalized to JSON string + const parsed = JSON.parse(result.content); + assert.equal(parsed.type, 'text'); + assert.equal(parsed.file.totalLines, 50); +}); + +test('LI-005: PostToolUse with object MCP response → classifiable via Level 2', () => { + // Simulates MCP hold_ball returning structured object (not pre-serialized string) + const entries = [ + { + hook_event_name: 'PostToolUse', + tool_name: 'cat_cafe_hold_ball', + tool_response: { status: 'ok', held: true, taskId: 'hold-42' }, + tool_use_id: 'tu_mcp', + }, + ]; + const msgs = hookEntriesToAgentMessages(entries, { catId: 'opus' }); + const result = msgs.find((m) => m.type === 'tool_result'); + assert.ok(result); + // Normalized content is parseable JSON with status:'ok' + const parsed = JSON.parse(result.content); + assert.equal(parsed.status, 'ok'); +}); + +test('LI-005: PostToolUse without tool_response → content undefined', () => { + const entries = [ + { + hook_event_name: 'PostToolUse', + tool_name: 'Read', + tool_use_id: 'tu_noresponse', + }, + ]; + const msgs = hookEntriesToAgentMessages(entries, { catId: 'opus' }); + const result = msgs.find((m) => m.type === 'tool_result'); + assert.ok(result); + assert.equal(result.content, undefined); + assert.equal(result.toolResultStatus, 'ok', 'PostToolUse still success even without response'); +}); + +// --------------------------------------------------------------------------- +// LI-005: PostToolUseFailure → tool_result(error) bridge +// --------------------------------------------------------------------------- + +test('LI-005: PostToolUseFailure → tool_result with error status', () => { + const entries = [ + { + hook_event_name: 'PostToolUseFailure', + tool_name: 'cat_cafe_hold_ball', + tool_response: 'Rate limit exceeded', + tool_use_id: 'tu_fail', + }, + ]; + const msgs = hookEntriesToAgentMessages(entries, { catId: 'opus' }); + assert.equal(msgs.length, 1, 'PostToolUseFailure emits tool_result only (no tool_use)'); + assert.equal(msgs[0].type, 'tool_result'); + assert.equal(msgs[0].toolResultStatus, 'error'); + assert.equal(msgs[0].content, 'Rate limit exceeded'); + assert.equal(msgs[0].toolUseId, 'tu_fail'); +}); + +test('LI-005: PostToolUseFailure with structured response → normalized', () => { + const entries = [ + { + hook_event_name: 'PostToolUseFailure', + tool_response: { error: 'connection_refused', code: 429 }, + tool_use_id: 'tu_fail2', + }, + ]; + const msgs = hookEntriesToAgentMessages(entries, { catId: 'opus' }); + assert.equal(msgs.length, 1); + assert.equal(msgs[0].toolResultStatus, 'error'); + const parsed = JSON.parse(msgs[0].content); + assert.equal(parsed.error, 'connection_refused'); +}); diff --git a/packages/api/test/f232-thread-artifacts-aggregator.test.js b/packages/api/test/f232-thread-artifacts-aggregator.test.js index f892cbc9f5..3044b9c588 100644 --- a/packages/api/test/f232-thread-artifacts-aggregator.test.js +++ b/packages/api/test/f232-thread-artifacts-aggregator.test.js @@ -193,7 +193,15 @@ test('collectAllThreadMessages paginates a REAL store with no overlap (oldest→ const base = Date.now(); // 250 > THREAD_SCAN_PAGE(200) → 强制多页 for (let i = 0; i < 250; i++) { - store.append({ userId: 'u', catId: 'opus-48', content: `m${i}`, mentions: [], timestamp: base + i, threadId: 'T' }); + store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, + userId: 'u', + catId: 'opus-48', + content: `m${i}`, + mentions: [], + timestamp: base + i, + threadId: 'T', + }); } const all = await collectAllThreadMessages(store, 'T'); const uniqueIds = new Set(all.map((m) => m.id)); @@ -287,8 +295,17 @@ test('getByThreadBefore (in-memory) uses effective order time — queued→deliv const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); const base = Date.now(); - store.append({ userId: 'u', catId: 'c', content: 'older', mentions: [], timestamp: base + 50, threadId: 'T' }); + store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, + userId: 'u', + catId: 'c', + content: 'older', + mentions: [], + timestamp: base + 50, + threadId: 'T', + }); const queued = store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'u', catId: 'c', content: 'queued', diff --git a/packages/api/test/f232-thread-artifacts-endpoint.test.js b/packages/api/test/f232-thread-artifacts-endpoint.test.js index 7dd908ca74..a890690dbc 100644 --- a/packages/api/test/f232-thread-artifacts-endpoint.test.js +++ b/packages/api/test/f232-thread-artifacts-endpoint.test.js @@ -148,6 +148,7 @@ describe('GET /api/threads/:threadId/artifacts (F232)', () => { const base = Date.now(); // earliest message carries the file artifact, then push 59 newer plain messages past the default-50 window messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'alice', catId: 'opus-48', content: '', @@ -160,6 +161,7 @@ describe('GET /api/threads/:threadId/artifacts (F232)', () => { }); for (let i = 1; i <= 59; i++) { messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'alice', catId: 'opus-48', content: `m${i}`, diff --git a/packages/api/test/f232-thread-artifacts-redis.test.js b/packages/api/test/f232-thread-artifacts-redis.test.js index 0c5369b7ce..03aa4ce8cb 100644 --- a/packages/api/test/f232-thread-artifacts-redis.test.js +++ b/packages/api/test/f232-thread-artifacts-redis.test.js @@ -77,6 +77,7 @@ describe('F232 thread artifacts — Redis-backed (AC-A6)', { skip: redisIsolatio ]; for (const { ts, block } of rows) { await store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'u', catId: 'opus-48', content: '', @@ -113,6 +114,7 @@ describe('F232 thread artifacts — Redis-backed (AC-A6)', { skip: redisIsolatio // gap.pdf 的 artifact 从 GET /api/threads/:threadId/artifacts 漏聚合。必须用 effective score。 const append = (ts, fileName, deliveryStatus) => store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'u', catId: 'opus-48', content: '', @@ -149,6 +151,7 @@ describe('F232 thread artifacts — Redis-backed (AC-A6)', { skip: redisIsolatio it('thread index isolates: getByThread(other) does not leak this thread artifacts', async () => { await store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'u', catId: 'opus-48', content: '', diff --git a/packages/api/test/f257-active-actionable-stage.test.js b/packages/api/test/f257-active-actionable-stage.test.js new file mode 100644 index 0000000000..f3098fa18d --- /dev/null +++ b/packages/api/test/f257-active-actionable-stage.test.js @@ -0,0 +1,355 @@ +/** + * F257 #6 slice 6b (rework per sol R1 + operator option B) — 判据① + * activeStage / actionableStage read-model contract tests. + * + * Background (original incident, V2 thread msg 0001784469056616-000054): + * Console rendered the SYNTHESIZED `governance.decision === 'pending'` + * (produced from any alive/dormant verdict) as "待处理 / needs operator + * decision" while NO governance Candidate existed — operator asked + * "是要我审批吗 / 为什么看不到待审内容". The 固化 boundary (main thread msg + * 0001784469935300-000115): the read model must distinguish + * - activeStage: the loop's REAL stage (unmeasurable → tracing), and + * - actionableStage: derived ONLY from real pending Candidate count. + * Candidate projection is not wired yet (option B) → the API must honestly + * report source:'unavailable' instead of guessing from governance.pending. + * + * Covers sol R1 regressions: + * 1. unmeasurable → active=tracing, actionable=null; + * 2. governance lifecycle + 0 candidate → 无需动作; + * 3. N candidate → actionable=governance + N; + * 4. activeStage ≠ actionableStage is representable; + * 5. no candidate provider → provenance gap (unavailable), never pending-derived. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import Fastify from 'fastify'; + +// ── Minimal FakeRedis (InjectionTraceStore needs ZSET/SET/SCAN) ── +class FakeRedis { + constructor() { + this.kv = new Map(); + this.sorted = new Map(); + this.sets = new Map(); + } + async set(key, value) { + this.kv.set(key, value); + return 'OK'; + } + async get(key) { + return this.kv.get(key) ?? null; + } + async del(key) { + this.kv.delete(key); + return 1; + } + async zadd(key, score, member) { + const s = this.sorted.get(key) ?? new Map(); + s.set(member, score); + this.sorted.set(key, s); + return 1; + } + async zcard(key) { + return this.sorted.get(key)?.size ?? 0; + } + async zrevrange(key, start, stop) { + const s = this.sorted.get(key); + if (!s) return []; + return [...s.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(start, stop + 1) + .map(([m]) => m); + } + async zrangebyscore(key, min, max) { + const s = this.sorted.get(key); + if (!s) return []; + return [...s.entries()] + .filter(([, sc]) => sc >= min && sc <= max) + .sort((a, b) => a[1] - b[1]) + .map(([m]) => m); + } + async zrem(key, member) { + return this.sorted.get(key)?.delete(member) ? 1 : 0; + } + async sadd(key, ...members) { + const s = this.sets.get(key) ?? new Set(); + for (const m of members) s.add(m); + this.sets.set(key, s); + return members.length; + } + async smembers(key) { + return [...(this.sets.get(key) ?? [])]; + } + async scan(_c, ...args) { + const i = args.indexOf('MATCH'); + const pat = i >= 0 ? args[i + 1] : '*'; + const rx = new RegExp(`^${pat.replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&').replace(/\*/g, '.*')}$`); + return ['0', [...new Set([...this.kv.keys(), ...this.sorted.keys()])].filter((k) => rx.test(k))]; + } +} + +const SESSION_HEADERS = { 'x-test-session-user': 'test-user' }; + +function makeSummary(threadId, turnId, timestamp, catId, segments) { + return { + turnId, + threadId, + catId, + timestamp, + segments, + delivery: [], + totalCharCount: 100, + totalTokenEstimate: 25, + totalSegmentsObserved: segments.length, + totalSegmentsAbsent: 0, + durationMs: 5, + }; +} + +function makeSegment(segmentId, opts = {}) { + return { + segmentId, + stage: 'session-init', + status: opts.status ?? 'observed', + contentHash: 'hash-1', + charCount: opts.charCount ?? 100, + tokenEstimate: 25, + version: opts.version ?? 1, + pipelineStatus: opts.pipelineStatus ?? 'fired', + }; +} + +function makeJudgment(segmentId, verdict, evaluatedAt) { + return { + segmentId, + verdict, + injectionCount: 10, + violationCount: 1, + correlationConfidence: 'high', + evaluatedAt, + runId: `run-${verdict}`, + segmentVersion: 1, + }; +} + +async function buildApp({ judgment = null, candidateCount, withProvider = false, providerFn } = {}) { + const { InjectionTraceStore } = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + const { segmentLifelineRoutes } = await import('../dist/routes/segment-lifeline.js'); + const redis = new FakeRedis(); + const traceStore = new InjectionTraceStore(redis); + const now = Date.now(); + await traceStore.persist(makeSummary('thread-A', 'turn-1', now - 1000, 'opus', [makeSegment('S-x')]), { + threadId: 'thread-A', + turnId: 'turn-1', + raw: '', + }); + + const opts = { traceStore }; + if (judgment) { + opts.judgmentCache = { getHistory: async () => [judgment] }; + } + if (providerFn) { + opts.resolvePendingCandidateCount = providerFn; + } else if (withProvider) { + opts.resolvePendingCandidateCount = async () => candidateCount; + } + + const app = Fastify({ logger: false }); + app.addHook('preHandler', async (request) => { + const u = request.headers['x-test-session-user']; + if (typeof u === 'string' && u.trim()) request.sessionUserId = u.trim(); + }); + await app.register(segmentLifelineRoutes, opts); + await app.ready(); + return app; +} + +async function getLifeline(app, segmentId = 'S-x') { + const res = await app.inject({ method: 'GET', url: `/api/segment-lifeline/${segmentId}`, headers: SESSION_HEADERS }); + assert.equal(res.statusCode, 200, `expected 200, got ${res.statusCode}: ${res.body}`); + return JSON.parse(res.body); +} + +// ── Unit: deriveActiveStage (loop model, not one-way pipeline) ── + +describe('判据① deriveActiveStage — real loop stage', () => { + let deriveActiveStage; + const epoch = (verdict) => ({ + version: 1, + origin: 'manifest', + startedAt: 0, + status: 'idle', + isActive: true, + tracing: null, + eval: verdict === undefined ? null : { verdict, injectionCount: 10, violationCount: 1, evaluatedAt: 1000 }, + governance: null, + events: [], + }); + + test('setup: import', async () => { + ({ deriveActiveStage } = await import('../dist/routes/segment-lifeline-chain.js')); + assert.equal(typeof deriveActiveStage, 'function'); + }); + + test('unmeasurable → tracing (the 固化 core: active 回 tracing)', async () => { + ({ deriveActiveStage } = await import('../dist/routes/segment-lifeline-chain.js')); + assert.equal(deriveActiveStage(epoch('unmeasurable')), 'tracing'); + }); + + test('observability-debt / needs-denominator → tracing (cannot conclude → keep collecting)', async () => { + ({ deriveActiveStage } = await import('../dist/routes/segment-lifeline-chain.js')); + assert.equal(deriveActiveStage(epoch('observability-debt')), 'tracing'); + assert.equal(deriveActiveStage(epoch('needs-denominator')), 'tracing'); + }); + + test('retire-candidate → tracing (eval rejected → re-enter tracing)', async () => { + ({ deriveActiveStage } = await import('../dist/routes/segment-lifeline-chain.js')); + assert.equal(deriveActiveStage(epoch('retire-candidate')), 'tracing'); + }); + + test('alive / dormant → governance (parked, informational)', async () => { + ({ deriveActiveStage } = await import('../dist/routes/segment-lifeline-chain.js')); + assert.equal(deriveActiveStage(epoch('alive')), 'governance'); + assert.equal(deriveActiveStage(epoch('dormant')), 'governance'); + }); + + test('no eval yet → tracing; undefined epoch → tracing', async () => { + ({ deriveActiveStage } = await import('../dist/routes/segment-lifeline-chain.js')); + assert.equal(deriveActiveStage(epoch(undefined)), 'tracing'); + assert.equal(deriveActiveStage(undefined), 'tracing'); + }); +}); + +// ── Route contract: activeStage + actionable in the response ── + +describe('判据① route contract — activeStage / actionable', () => { + test('R1-1: unmeasurable → activeStage=tracing, actionable null + unavailable', async () => { + const app = await buildApp({ judgment: makeJudgment('S-x', 'unmeasurable', Date.now() - 500) }); + const body = await getLifeline(app); + assert.equal(body.activeStage, 'tracing', 'unmeasurable must return the loop to tracing'); + assert.deepEqual(body.actionable, { stage: null, candidateCount: null, source: 'unavailable' }); + await app.close(); + }); + + test('R1-2: alive (governance lifecycle) + no provider → honest gap, NOT pending-derived', async () => { + const app = await buildApp({ judgment: makeJudgment('S-x', 'alive', Date.now() - 500) }); + const body = await getLifeline(app); + assert.equal(body.activeStage, 'governance'); + // The synthesized governance.pending exists in the epoch data… + const active = body.chain.find((e) => e.isActive); + assert.equal(active.governance?.decision, 'pending', 'producer still records lifecycle stage'); + // …but actionable must NOT be inferred from it (original incident's false signal) + assert.deepEqual(body.actionable, { stage: null, candidateCount: null, source: 'unavailable' }); + await app.close(); + }); + + test('R1-3a: provider 0 candidates → 无需动作 (stage null, count 0)', async () => { + const app = await buildApp({ + judgment: makeJudgment('S-x', 'alive', Date.now() - 500), + withProvider: true, + candidateCount: 0, + }); + const body = await getLifeline(app); + assert.deepEqual(body.actionable, { stage: null, candidateCount: 0, source: 'candidate-count' }); + await app.close(); + }); + + test('R1-3b: provider N=2 candidates → actionable=governance + count', async () => { + const app = await buildApp({ + judgment: makeJudgment('S-x', 'alive', Date.now() - 500), + withProvider: true, + candidateCount: 2, + }); + const body = await getLifeline(app); + assert.deepEqual(body.actionable, { stage: 'governance', candidateCount: 2, source: 'candidate-count' }); + await app.close(); + }); + + test('R1-5: provider returns null → provenance gap (unavailable)', async () => { + const app = await buildApp({ + judgment: makeJudgment('S-x', 'alive', Date.now() - 500), + withProvider: true, + candidateCount: null, + }); + const body = await getLifeline(app); + assert.deepEqual(body.actionable, { stage: null, candidateCount: null, source: 'unavailable' }); + await app.close(); + }); + + test('R1-4: activeStage ≠ actionable.stage is representable (governance active, nothing actionable)', async () => { + const app = await buildApp({ + judgment: makeJudgment('S-x', 'dormant', Date.now() - 500), + withProvider: true, + candidateCount: 0, + }); + const body = await getLifeline(app); + assert.equal(body.activeStage, 'governance'); + assert.equal(body.actionable.stage, null, 'active at governance does NOT imply actionable'); + await app.close(); + }); + + test('no judgment at all → activeStage tracing + unavailable', async () => { + const app = await buildApp({}); + const body = await getLifeline(app); + assert.equal(body.activeStage, 'tracing'); + assert.deepEqual(body.actionable, { stage: null, candidateCount: null, source: 'unavailable' }); + await app.close(); + }); + + // R2 P1-4: the decisive cross-state — active ≠ actionable, BOTH non-empty. + test('R2 P1-4: retire-candidate + 2 real candidates → active=tracing AND actionable=governance(2)', async () => { + const app = await buildApp({ + judgment: makeJudgment('S-x', 'retire-candidate', Date.now() - 500), + withProvider: true, + candidateCount: 2, + }); + const body = await getLifeline(app); + assert.equal(body.activeStage, 'tracing', 'retire-candidate loops back to tracing'); + assert.deepEqual(body.actionable, { stage: 'governance', candidateCount: 2, source: 'candidate-count' }); + const active = body.chain.find((e) => e.isActive); + assert.equal(active.governance, null, 'no synthesized governance.pending for retire-candidate'); + await app.close(); + }); +}); + +// ── R2 P2-3: provider seam fail-safe (fail-closed to honest gap) ── + +describe('判据① provider fail-safe (R2 P2-3)', () => { + const aliveJudgment = () => makeJudgment('S-x', 'alive', Date.now() - 500); + + test('provider throws → 200 + unavailable (endpoint must not 500)', async () => { + const app = await buildApp({ + judgment: aliveJudgment(), + providerFn: async () => { + throw new Error('projection store down'); + }, + }); + const body = await getLifeline(app); + assert.deepEqual(body.actionable, { stage: null, candidateCount: null, source: 'unavailable' }); + await app.close(); + }); + + for (const [label, bad] of [ + ['negative', -1], + ['fractional', 1.5], + ['NaN', Number.NaN], + ]) { + test(`provider returns ${label} count → unavailable (never a guessed count)`, async () => { + const app = await buildApp({ judgment: aliveJudgment(), providerFn: async () => bad }); + const body = await getLifeline(app); + assert.deepEqual( + body.actionable, + { stage: null, candidateCount: null, source: 'unavailable' }, + `${label} count must degrade to the honest gap`, + ); + await app.close(); + }); + } + + test('provider returning valid 3 still works after fail-safe guard', async () => { + const app = await buildApp({ judgment: aliveJudgment(), providerFn: async () => 3 }); + const body = await getLifeline(app); + assert.deepEqual(body.actionable, { stage: 'governance', candidateCount: 3, source: 'candidate-count' }); + await app.close(); + }); +}); diff --git a/packages/api/test/f257-eval-window.test.js b/packages/api/test/f257-eval-window.test.js new file mode 100644 index 0000000000..bb272b74c2 --- /dev/null +++ b/packages/api/test/f257-eval-window.test.js @@ -0,0 +1,587 @@ +/** + * F257 #6 slice 6c — 判据② eval window / denominator provenance contract tests. + * + * Root cause (static call chain, sol proposal): the lifeline endpoint's + * `window` is the CURRENT QUERY window; `SegmentJudgment` had the precise + * eval `window + denominatorKind`, but `CachedJudgment` persisted only + * counts + `evaluatedAt` — so the UI projected incomparable metrics + * (tracing(18) from the query window vs eval injectionCount=0 from a + * historical eval window) into the same context as if contradictory. + * + * Contract (sol, source thread 2026-07-22): + * - producer-written CachedJudgment MUST carry window + denominatorKind; + * - only legacy Redis JSON reads may lack them → explicit null (fail-visible); + * - window semantics [startMs, endMs) — evaluatedAt is NOT a window; + * - the judgment's OWN eval window must never be replaced by the query window. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import Fastify from 'fastify'; + +// ── Minimal FakeRedis (InjectionTraceStore needs ZSET/SET/SCAN; SegmentJudgmentCache needs HASH) ── +class FakeRedis { + constructor() { + this.kv = new Map(); + this.sorted = new Map(); + this.sets = new Map(); + this.hashes = new Map(); + } + async set(key, value) { + this.kv.set(key, value); + return 'OK'; + } + async get(key) { + return this.kv.get(key) ?? null; + } + async del(key) { + this.kv.delete(key); + return 1; + } + async hset(key, field, value) { + const h = this.hashes.get(key) ?? new Map(); + h.set(field, value); + this.hashes.set(key, h); + return 1; + } + async hget(key, field) { + return this.hashes.get(key)?.get(field) ?? null; + } + pipeline() { + const ops = []; + const self = this; + const pipe = { + hset(key, field, value) { + ops.push({ op: 'hset', key, field, value }); + return pipe; + }, + hget(key, field) { + ops.push({ op: 'hget', key, field }); + return pipe; + }, + zadd(key, score, member) { + ops.push({ op: 'zadd', key, score, member }); + return pipe; + }, + async exec() { + const results = []; + for (const op of ops) { + if (op.op === 'hset') { + await self.hset(op.key, op.field, op.value); + results.push([null, 1]); + } else if (op.op === 'hget') { + results.push([null, await self.hget(op.key, op.field)]); + } else if (op.op === 'zadd') { + await self.zadd(op.key, op.score, op.member); + results.push([null, 1]); + } + } + return results; + }, + }; + return pipe; + } + async zadd(key, score, member) { + const s = this.sorted.get(key) ?? new Map(); + s.set(member, score); + this.sorted.set(key, s); + return 1; + } + async zcard(key) { + return this.sorted.get(key)?.size ?? 0; + } + async zrevrange(key, start, stop) { + const s = this.sorted.get(key); + if (!s) return []; + return [...s.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(start, stop + 1) + .map(([m]) => m); + } + async zrangebyscore(key, min, max) { + const s = this.sorted.get(key); + if (!s) return []; + const minN = min === '-inf' ? -Infinity : Number(min); + const maxN = max === '+inf' ? Infinity : Number(max); + return [...s.entries()] + .filter(([, sc]) => sc >= minN && sc <= maxN) + .sort((a, b) => a[1] - b[1]) + .map(([m]) => m); + } + async zrem(key, member) { + return this.sorted.get(key)?.delete(member) ? 1 : 0; + } + async sadd(key, ...members) { + const s = this.sets.get(key) ?? new Set(); + for (const m of members) s.add(m); + this.sets.set(key, s); + return members.length; + } + async smembers(key) { + return [...(this.sets.get(key) ?? [])]; + } + async scan(_c, ...args) { + const i = args.indexOf('MATCH'); + const pat = i >= 0 ? args[i + 1] : '*'; + const rx = new RegExp(`^${pat.replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&').replace(/\*/g, '.*')}$`); + return ['0', [...new Set([...this.kv.keys(), ...this.sorted.keys()])].filter((k) => rx.test(k))]; + } +} + +const SESSION_HEADERS = { 'x-test-session-user': 'test-user' }; + +function makeSummary(threadId, turnId, timestamp, catId, segments) { + return { + turnId, + threadId, + catId, + timestamp, + segments, + delivery: [], + totalCharCount: 100, + totalTokenEstimate: 25, + totalSegmentsObserved: segments.length, + totalSegmentsAbsent: 0, + durationMs: 5, + }; +} + +function makeSegment(segmentId, opts = {}) { + return { + segmentId, + stage: 'session-init', + status: opts.status ?? 'observed', + contentHash: 'hash-1', + charCount: opts.charCount ?? 100, + tokenEstimate: 25, + version: opts.version ?? 1, + pipelineStatus: opts.pipelineStatus ?? 'fired', + }; +} + +/** CachedJudgment shape AFTER slice 6c — producer writes carry window + denominatorKind. */ +function makeJudgment(segmentId, verdict, evaluatedAt, overrides = {}) { + return { + segmentId, + verdict, + injectionCount: 10, + violationCount: 1, + correlationConfidence: 'window', + evaluatedAt, + runId: `run-${verdict}`, + segmentVersion: 1, + window: { startMs: evaluatedAt - 86_400_000, endMs: evaluatedAt }, // judgment's OWN 1d eval window + denominatorKind: 'fired-count', + ...overrides, + }; +} + +async function buildApp({ + judgment = null, + segments = null, + turns = null, + overrideEvents = null, + overrideState = null, + rawCacheEntries = null, +} = {}) { + const { InjectionTraceStore } = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + const { segmentLifelineRoutes } = await import('../dist/routes/segment-lifeline.js'); + const redis = new FakeRedis(); + const traceStore = new InjectionTraceStore(redis); + const now = Date.now(); + if (turns) { + // Caller-controlled turn set (e.g. >MAX_OBSERVATIONS cap regression). + for (const turn of turns) { + const threadId = turn.threadId ?? 'thread-A'; + await traceStore.persist(makeSummary(threadId, turn.turnId, turn.timestamp, 'opus', turn.segments), { + threadId, + turnId: turn.turnId, + raw: '', + }); + } + } else { + await traceStore.persist(makeSummary('thread-A', 'turn-1', now - 1000, 'opus', segments ?? [makeSegment('S-x')]), { + threadId: 'thread-A', + turnId: 'turn-1', + raw: '', + }); + } + + const opts = { traceStore }; + if (rawCacheEntries) { + // Real cache seam (sol R6 P2): seed raw JSON so normalization actually runs. + const { SegmentJudgmentCache } = await import('../dist/domains/prompt-hooks/SegmentJudgmentCache.js'); + for (const e of rawCacheEntries) { + await redis.hset('segment-judgment-latest', e.segmentId, e.json); + await redis.zadd(`segment-judgment-history:${e.segmentId}`, e.evaluatedAt, e.json); + } + opts.judgmentCache = new SegmentJudgmentCache(redis); + } else if (judgment) { + opts.judgmentCache = { getHistory: async () => [judgment] }; + } + if (overrideEvents || overrideState) { + opts.overrideStore = { + listEvents: async () => overrideEvents ?? [], + listOverrides: async () => (overrideState ? [overrideState] : []), + listVersions: async () => [], + }; + } + + const app = Fastify({ logger: false }); + app.addHook('preHandler', async (request) => { + const u = request.headers['x-test-session-user']; + if (typeof u === 'string' && u.trim()) request.sessionUserId = u.trim(); + }); + await app.register(segmentLifelineRoutes, opts); + await app.ready(); + return app; +} + +async function getLifeline(app, segmentId = 'S-x') { + const res = await app.inject({ method: 'GET', url: `/api/segment-lifeline/${segmentId}`, headers: SESSION_HEADERS }); + assert.equal(res.statusCode, 200, `expected 200, got ${res.statusCode}: ${res.body}`); + return JSON.parse(res.body); +} + +// ── Unit: buildVersionChain judgment attribution ── + +describe('判据② chain builder — eval window/denominator attribution', () => { + async function buildChainWith(judgment) { + const { buildVersionChain } = await import('../dist/routes/segment-lifeline-chain.js'); + return buildVersionChain({ + manifestVersion: 1, + overrideEvents: [], + observations: [], + judgmentHistory: [judgment], + currentContentVersion: null, + }); + } + + test('epoch.eval carries the judgment OWN window + denominatorKind', async () => { + const j = makeJudgment('S-x', 'alive', 9_000_000); + const { chain } = await buildChainWith(j); + const ev = chain[0].eval; + assert.ok(ev, 'eval stage should be attached'); + assert.deepEqual(ev.evalWindow, { startMs: 9_000_000 - 86_400_000, endMs: 9_000_000 }); + assert.equal(ev.denominatorKind, 'fired-count'); + assert.equal(ev.evaluatedAt, 9_000_000, 'evaluatedAt preserved as point-in-time, not a window'); + }); + + test('legacy judgment (window/denominatorKind undefined) → explicit null, never guessed', async () => { + const legacy = makeJudgment('S-x', 'alive', 9_000_000); + delete legacy.window; + delete legacy.denominatorKind; + const { chain } = await buildChainWith(legacy); + const ev = chain[0].eval; + assert.ok(ev); + assert.equal(ev.evalWindow, null, 'missing window must surface as null, not derived from evaluatedAt'); + assert.equal(ev.denominatorKind, null, 'missing denominatorKind must surface as null'); + }); + + test('per-version attribution: two judgments keep their own windows on their own epochs', async () => { + const { buildVersionChain } = await import('../dist/routes/segment-lifeline-chain.js'); + const v1Judgment = makeJudgment('S-x', 'dormant', 5_000_000, { segmentVersion: 1 }); + const v2Judgment = makeJudgment('S-x', 'alive', 9_000_000, { + segmentVersion: 2, + window: { startMs: 9_000_000 - 3_600_000, endMs: 9_000_000 }, // v2 used a 1h eval window + }); + const { chain } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [ + { + eventId: 'e1', + hookId: 'S-x', + action: 'content-set', + timestamp: 6_000_000, + actorId: 'system', + source: 'system', + epochVersion: 2, + contentVersion: 2, + }, + ], + observations: [], + judgmentHistory: [v1Judgment, v2Judgment], + currentContentVersion: 2, + }); + const v1 = chain.find((e) => e.version === 1); + const v2 = chain.find((e) => e.version === 2); + assert.deepEqual(v1.eval.evalWindow, { startMs: 5_000_000 - 86_400_000, endMs: 5_000_000 }); + assert.deepEqual(v2.eval.evalWindow, { startMs: 9_000_000 - 3_600_000, endMs: 9_000_000 }); + }); +}); + +// ── Route contract: eval window ≠ query window ── + +describe('判据② route contract — eval window vs query window', () => { + test('response.window stays the QUERY window; epoch eval carries the judgment OWN window', async () => { + const now = Date.now(); + // Judgment evaluated 10 days ago over a 1-day eval window — OUTSIDE the default 7d query window. + const judgment = makeJudgment('S-x', 'alive', now - 10 * 86_400_000); + const app = await buildApp({ judgment }); + const body = await getLifeline(app); + + // Query window ≈ [now-7d, now] + assert.ok(Math.abs(body.window.endMs - now) < 5000, 'response.window.endMs is the query end (~now)'); + assert.ok(body.window.startMs > now - 8 * 86_400_000, 'response.window.startMs is ~7d back'); + + const epoch = body.chain.find((e) => e.version === 1); + assert.ok(epoch.eval, 'eval stage present'); + assert.deepEqual( + epoch.eval.evalWindow, + { startMs: now - 11 * 86_400_000, endMs: now - 10 * 86_400_000 }, + 'eval window must be the judgment OWN historical window, not the query window', + ); + assert.equal(epoch.eval.denominatorKind, 'fired-count'); + }); + + test('legacy cached judgment → API exposes explicit null provenance gap (fail-visible)', async () => { + const now = Date.now(); + const legacy = makeJudgment('S-x', 'alive', now - 1000); + delete legacy.window; + delete legacy.denominatorKind; + const app = await buildApp({ judgment: legacy }); + const body = await getLifeline(app); + + const epoch = body.chain.find((e) => e.version === 1); + assert.ok(epoch.eval); + assert.equal(epoch.eval.evalWindow, null, 'API must surface the provenance gap, not guess'); + assert.equal(epoch.eval.denominatorKind, null); + }); +}); + +// ── P1 (sol R6): completeness matrix — aggregate counts are EXACT full-window +// scans; only the DETAIL row list is capped at MAX_OBSERVATIONS. An unsampled +// epoch must never pose as zero-data (the R5 lower-bound model is superseded: +// counts carry no cap at all, the response carries observationsCapped for the +// detail list alone). +// +// Matrix: {<100, =100, >100} × {single-epoch, multi-epoch} × {fired, mixed +// fired/observe-only} — every cell asserts exact counts + detail cap flag. + +describe('P1 (sol R6) route contract — exact aggregate counts + detail-list completeness', () => { + const firedTurn = (turnId, timestamp, threadId) => ({ turnId, timestamp, threadId, segments: [makeSegment('S-x')] }); + const observedTurn = (turnId, timestamp, threadId) => ({ + turnId, + timestamp, + threadId, + segments: [makeSegment('S-x', { pipelineStatus: 'observed' })], + }); + + test('<100 single-epoch all-fired → exact counts, no cap', async () => { + const now = Date.now(); + const turns = [firedTurn('t1', now - 3000), firedTurn('t2', now - 2000), firedTurn('t3', now - 1000)]; + const body = await getLifeline(await buildApp({ turns })); + const epoch = body.chain.find((e) => e.version === 1); + assert.equal(epoch.tracing.observationCount, 3); + assert.equal(epoch.tracing.firedCount, 3); + assert.equal(body.observations.length, 3); + assert.equal(body.observationsCapped, false); + }); + + test('<100 single-epoch observe-only → observation, NEVER injection (isFired semantics)', async () => { + const body = await getLifeline(await buildApp({ segments: [makeSegment('S-x', { pipelineStatus: 'observed' })] })); + const epoch = body.chain.find((e) => e.version === 1); + assert.equal(epoch.tracing.observationCount, 1, 'the row IS an observation'); + assert.equal(epoch.tracing.firedCount, 0, 'observe-only must NOT count as an injection (fired-count)'); + assert.equal(body.observationsCapped, false); + }); + + test('=100 single-epoch all-fired → exact 100, NOT capped (exactly-100 is complete)', async () => { + const now = Date.now(); + const turns = []; + for (let i = 0; i < 100; i++) turns.push(firedTurn(`t-eq-${i}`, now - (i + 1) * 60_000)); + const body = await getLifeline(await buildApp({ turns })); + const epoch = body.chain.find((e) => e.version === 1); + assert.equal(epoch.tracing.observationCount, 100); + assert.equal(epoch.tracing.firedCount, 100); + assert.equal(body.observations.length, 100); + assert.equal(body.observationsCapped, false, 'exactly 100 rows is complete — nothing exists beyond the cap'); + }); + + test('>100 single-epoch all-fired (101) → counts exact 101, detail list capped with provenance', async () => { + const now = Date.now(); + const turns = []; + for (let i = 0; i < 101; i++) turns.push(firedTurn(`t-gt-${i}`, now - (i + 1) * 60_000)); + const body = await getLifeline(await buildApp({ turns })); + const epoch = body.chain.find((e) => e.version === 1); + assert.equal(epoch.tracing.observationCount, 101, 'aggregate count is the EXACT full-window total, not 100'); + assert.equal(epoch.tracing.firedCount, 101); + assert.equal(body.observations.length, 100, 'detail rows stay capped at MAX_OBSERVATIONS'); + assert.equal(body.observationsCapped, true, 'detail-list completeness provenance'); + // Detail rows are the 100 MOST RECENT (deterministic sample). + assert.equal(body.observations[0].turnId, 't-gt-0'); + }); + + test('>100 multi-thread multi-epoch: 101st row on ACTIVE v2 → v2 tracing exact, never null (sol R6 repro)', async () => { + const now = Date.now(); + const T = now - 55 * 60_000; // v2 activated 55min ago + const turns = []; + // thread-A: 100 fired rows all BEFORE T → v1 + for (let i = 0; i < 100; i++) turns.push(firedTurn(`tA-${i}`, now - (60 + i) * 60_000, 'thread-A')); + // thread-B: the 101st row AFTER T → active v2 (persisted second — R5 dropped it under the global cap) + turns.push(firedTurn('tB-101', now - 60_000, 'thread-B')); + const app = await buildApp({ + turns, + overrideEvents: [ + { + eventId: 'e-v2', + hookId: 'S-x', + action: 'content-set', + timestamp: T, + actorId: 'system', + source: 'system', + epochVersion: 2, + contentVersion: 2, + }, + ], + overrideState: { hookId: 'S-x', enabled: true, contentVersion: 2 }, + }); + const body = await getLifeline(app); + + const v1 = body.chain.find((e) => e.version === 1); + const v2 = body.chain.find((e) => e.version === 2); + assert.ok(v2.isActive, 'v2 is the active epoch'); + assert.equal(v1.tracing.observationCount, 100); + assert.equal(v1.tracing.firedCount, 100); + assert.ok( + v2.tracing, + 'active epoch with a real observation must NEVER read as tracing:null (unsampled ≠ zero-data)', + ); + assert.equal(v2.tracing.observationCount, 1); + assert.equal(v2.tracing.firedCount, 1); + assert.equal(body.observations.length, 100); + assert.equal(body.observationsCapped, true); + assert.equal(body.observations[0].threadId, 'thread-B', 'newest row survives the detail cap'); + }); + + test('>100 mixed fired/observe-only (60 fired + 41 observe-only) → exact split, capped detail', async () => { + const now = Date.now(); + const turns = []; + for (let i = 0; i < 60; i++) turns.push(firedTurn(`tF-${i}`, now - (i + 1) * 60_000)); + for (let i = 0; i < 41; i++) turns.push(observedTurn(`tO-${i}`, now - (61 + i) * 60_000)); + const body = await getLifeline(await buildApp({ turns })); + const epoch = body.chain.find((e) => e.version === 1); + assert.equal(epoch.tracing.observationCount, 101); + assert.equal(epoch.tracing.firedCount, 60, 'observe-only rows never inflate the fired metric'); + assert.equal(body.observationsCapped, true); + }); + + test('<100 multi-epoch mixed → exact per-epoch split, no cap', async () => { + const now = Date.now(); + const T = now - 55 * 60_000; + const turns = [ + firedTurn('v1-a', now - 70 * 60_000), // before T → v1 + firedTurn('v1-b', now - 65 * 60_000), // before T → v1 + observedTurn('v2-a', now - 60_000), // after T → v2, observe-only + ]; + const app = await buildApp({ + turns, + overrideEvents: [ + { + eventId: 'e-v2', + hookId: 'S-x', + action: 'content-set', + timestamp: T, + actorId: 'system', + source: 'system', + epochVersion: 2, + contentVersion: 2, + }, + ], + overrideState: { hookId: 'S-x', enabled: true, contentVersion: 2 }, + }); + const body = await getLifeline(app); + const v1 = body.chain.find((e) => e.version === 1); + const v2 = body.chain.find((e) => e.version === 2); + assert.equal(v1.tracing.observationCount, 2); + assert.equal(v1.tracing.firedCount, 2); + assert.equal(v2.tracing.observationCount, 1); + assert.equal(v2.tracing.firedCount, 0); + assert.equal(body.observationsCapped, false); + }); +}); + +// ── P2 (sol R5): provenance gap kind — legacy-missing vs invalid-present ── + +describe('P2 (sol R5) route contract — gap kind must not be mislabeled', () => { + test('malformed-present window/denominator → invalid-present, distinct from legacy-missing', async () => { + // As produced by the real cache read seam (normalizeCachedJudgment) for a + // forged entry: value null + gap kind 'invalid-present'. + const forged = makeJudgment('S-x', 'alive', 9_000_000, { + window: null, + windowGap: 'invalid-present', + denominatorKind: null, + denominatorGap: 'invalid-present', + }); + const { buildVersionChain } = await import('../dist/routes/segment-lifeline-chain.js'); + const { chain } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [], + observations: [], + judgmentHistory: [forged], + currentContentVersion: null, + }); + const ev = chain[0].eval; + assert.equal(ev.evalWindow, null); + assert.equal(ev.evalWindowGap, 'invalid-present', 'corrupted provenance must NOT be labeled legacy-missing'); + assert.equal(ev.denominatorKind, null); + assert.equal(ev.denominatorGap, 'invalid-present'); + }); + + test('legacy-missing fields → gap kind legacy-missing (route)', async () => { + const now = Date.now(); + const legacy = makeJudgment('S-x', 'alive', now - 1000); + delete legacy.window; + delete legacy.denominatorKind; + const app = await buildApp({ judgment: legacy }); + const body = await getLifeline(app); + + const epoch = body.chain.find((e) => e.version === 1); + assert.equal(epoch.eval.evalWindowGap, 'legacy-missing'); + assert.equal(epoch.eval.denominatorGap, 'legacy-missing'); + }); + + test('explicit-null provenance → invalid-present through the REAL cache read seam (sol R6 P2)', async () => { + // The producer never writes null; a present-null field is malformed-present. + // `raw == null` cannot see the difference — classification must be by + // own-property presence, end-to-end through cache → chain → response. + const now = Date.now(); + const entry = makeJudgment('S-x', 'alive', now - 1000, { + window: null, + denominatorKind: null, + }); + delete entry.windowGap; + delete entry.denominatorGap; + const json = JSON.stringify(entry); + assert.ok(json.includes('"window":null'), 'fixture sanity: explicit null survives serialization'); + + const app = await buildApp({ + rawCacheEntries: [{ segmentId: 'S-x', evaluatedAt: now - 1000, json }], + }); + const body = await getLifeline(app); + + const epoch = body.chain.find((e) => e.version === 1); + assert.ok(epoch.eval); + assert.equal(epoch.eval.evalWindow, null); + assert.equal(epoch.eval.evalWindowGap, 'invalid-present', 'present-null is corrupted data, NOT a legacy gap'); + assert.equal(epoch.eval.denominatorKind, null); + assert.equal(epoch.eval.denominatorGap, 'invalid-present'); + }); + + test('absent provenance fields → legacy-missing through the REAL cache read seam (matrix control)', async () => { + const now = Date.now(); + const entry = makeJudgment('S-x', 'alive', now - 1000); + delete entry.window; + delete entry.denominatorKind; + delete entry.windowGap; + delete entry.denominatorGap; + const app = await buildApp({ + rawCacheEntries: [{ segmentId: 'S-x', evaluatedAt: now - 1000, json: JSON.stringify(entry) }], + }); + const body = await getLifeline(app); + + const epoch = body.chain.find((e) => e.version === 1); + assert.equal(epoch.eval.evalWindowGap, 'legacy-missing', 'absent keys are the legacy pre-6c shape'); + assert.equal(epoch.eval.denominatorGap, 'legacy-missing'); + }); +}); diff --git a/packages/api/test/f257-fix1-4path-mismatch-matrix.test.js b/packages/api/test/f257-fix1-4path-mismatch-matrix.test.js new file mode 100644 index 0000000000..eb9789fa43 --- /dev/null +++ b/packages/api/test/f257-fix1-4path-mismatch-matrix.test.js @@ -0,0 +1,345 @@ +/** + * sol R3 P1 补缺 — 四路径 mismatch gate 零副作用矩阵。 + * + * 证明 checkRoutingMismatch 在以下四条执行路径上均在所有副作用之前触发: + * 1. normal invocation-token(claim / buffer-consume / TTS 不触发) + * 2. agent-key(claim / TTS 不触发) + * 3. invocation-token + assign_work(DispatchProposal 不创建) + * 4. invocation-token + freshness-enabled(deliveryCursorStore 不触及) + * + * 设计:每条路径注入可观测 spy mock,断言 HELD 返回 + spy 未被调用。 + */ + +import './helpers/setup-cat-registry.js'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, test } from 'node:test'; +import { catRegistry, createCatId } from '@cat-cafe/shared'; + +function mkConfig(catId, patterns) { + return { + id: createCatId(catId), + name: `${catId}-name`, + displayName: `${catId}-display`, + avatar: `/avatars/${catId}.png`, + color: { primary: '#000000', secondary: '#ffffff' }, + mentionPatterns: patterns, + clientId: 'anthropic', + defaultModel: 'claude-sonnet-4-5-20250929', + mcpSupport: true, + }; +} + +// Reuse same ambiguity pair as callback-ambiguity tests — already registered by setup +for (const [catId, patterns] of [ + ['cbk-amb-a', ['@cbk-amb-a', '@回名']], + ['cbk-amb-b', ['@cbk-amb-b', '@回名']], +]) { + if (!catRegistry.has(catId)) catRegistry.register(catId, mkConfig(catId, patterns)); +} + +/** Thread store supporting cross-thread access (two threads, same user). */ +function makeXThreadStore() { + const threads = { + 't-source': { id: 't-source', title: 'source', preferredCats: [], createdBy: 'user-1' }, + 't-target': { id: 't-target', title: 'target', preferredCats: [], createdBy: 'user-1' }, + 't-cbk': { id: 't-cbk', title: 'cbk', preferredCats: [], createdBy: 'user-1' }, + }; + return { + get: (id) => threads[id] ?? null, + list: () => Object.values(threads), + getParticipants: () => ['opus', 'cbk-amb-a', 'cbk-amb-b'], + addParticipants: () => {}, + getParticipantsWithActivity: () => [], + updateParticipantActivity: () => {}, + updateLastActive: () => {}, + }; +} + +/** DispatchProposalStore spy — records create calls. */ +function makeProposalSpy() { + const calls = []; + return { + create: (input) => { + calls.push(input); + return input; + }, + findByClientMessageId: () => null, + getCalls: () => calls, + }; +} + +/** DeliveryCursorStore spy — records all method calls. */ +function makeCursorSpy() { + const calls = []; + const record = + (name) => + (...args) => { + calls.push({ method: name, args }); + return null; + }; + return { + getSeenCursor: record('getSeenCursor'), + ackSeenCursor: record('ackSeenCursor'), + getMentionAckCursor: record('getMentionAckCursor'), + ackMentionCursor: record('ackMentionCursor'), + getCalls: () => calls, + }; +} + +function createMockSocketManager() { + return { + broadcastAgentMessage() {}, + broadcastToRoom() {}, + emitToUser() {}, + }; +} + +function createMockRouter() { + return { + async *routeExecution(_uid, _msg, _tid, _umid, targets) { + yield { type: 'done', catId: targets[0], isFinal: true, timestamp: Date.now() }; + }, + }; +} + +function createMockInvocationRecordStore() { + return { create: () => ({ outcome: 'created', invocationId: 'inv-noop' }), update() {} }; +} + +function makeAgentKeyRegistry() { + return { + async verify() { + return { + ok: true, + record: { + agentKeyId: 'ak_matrix', + catId: 'opus', + userId: 'user-1', + secretHash: 'x', + salt: 'y', + scope: 'user-bound', + issuedAt: Date.now(), + expiresAt: Date.now() + 86400000, + }, + }; + }, + claimClientMessageId: () => true, + }; +} + +/** Content with embedded cc_rich audio block (needs TTS synthesis: has text, no url). */ +function audioContent(mention) { + const block = JSON.stringify({ v: 1, blocks: [{ kind: 'audio', v: 1, id: 'aud-spy', text: '测试语音' }] }); + return `${mention} 给你\n\`\`\`cc_rich\n${block}\n\`\`\``; +} + +/** Init VoiceBlockSynthesizer singleton with a counting mock TTS provider. */ +async function initTtsSpy(cacheDir) { + const { TtsRegistry } = await import('../dist/domains/cats/services/tts/TtsRegistry.js'); + const { initVoiceBlockSynthesizer } = await import('../dist/domains/cats/services/tts/VoiceBlockSynthesizer.js'); + const synthCalls = []; + const mockProvider = { + id: 'mock-tts', + model: 'test-v1', + async synthesize(req) { + synthCalls.push(req); + return { + audio: new Uint8Array([0, 1]), + format: 'wav', + durationSec: 0.1, + metadata: { provider: 'mock', model: 'test-v1', voice: req.voice }, + }; + }, + }; + const reg = new TtsRegistry(); + reg.register(mockProvider); + initVoiceBlockSynthesizer(reg, cacheDir); + return { getSynthCalls: () => synthCalls }; +} + +describe('sol R3 P1:4-path mismatch gate 零副作用矩阵', () => { + let registry; + let messageStore; + let ttsCacheDir; + + beforeEach(async () => { + const { InvocationRegistry } = await import( + '../dist/domains/cats/services/agents/invocation/InvocationRegistry.js' + ); + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + registry = new InvocationRegistry(); + messageStore = new MessageStore(); + ttsCacheDir = mkdtempSync(join(tmpdir(), 'tts-matrix-')); + }); + + afterEach(() => { + if (ttsCacheDir) rmSync(ttsCacheDir, { recursive: true, force: true }); + }); + + async function createApp(opts = {}) { + const { callbacksRoutes } = await import('../dist/routes/callbacks.js'); + const Fastify = (await import('fastify')).default; + const app = Fastify(); + await app.register(callbacksRoutes, { + registry, + messageStore, + socketManager: createMockSocketManager(), + router: createMockRouter(), + invocationRecordStore: createMockInvocationRecordStore(), + ...opts, + }); + return app; + } + + // ── Path 1: normal invocation-token ── + // Already covered by f257-fix1-callback-ambiguity.test.js (claim + buffer tests). + // Include a reference assertion here to complete the matrix in one file. + + test('path-1 invocation-token normal:mismatch → HELD(claim 不触发)', async () => { + const app = await createApp(); + const { invocationId, callbackToken } = await registry.create('user-1', 'opus', 't-cbk'); + const cmid = 'cmid-path1-matrix'; + + const held = await app.inject({ + method: 'POST', + url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { content: '@cbk-amb-b 给你', targetCats: ['cbk-amb-a'], clientMessageId: cmid }, + }); + assert.equal(JSON.parse(held.body).status, 'held'); + assert.equal(JSON.parse(held.body).reason, 'routing_mismatch'); + + // Proof: retry with SAME clientMessageId succeeds (claim was not consumed) + const retry = await app.inject({ + method: 'POST', + url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { content: '@cbk-amb-a 给你', targetCats: ['cbk-amb-a'], clientMessageId: cmid }, + }); + assert.notEqual(JSON.parse(retry.body).status, 'duplicate', 'claim must not fire before gate'); + }); + + // ── Path 2: agent-key (claim + TTS) ── + + test('path-2a agent-key:mismatch + audio → HELD(real TTS provider spy:synthCalls===0)', async () => { + const { getSynthCalls } = await initTtsSpy(ttsCacheDir); + const agentKeyReg = makeAgentKeyRegistry(); + const claimCalls = []; + agentKeyReg.claimClientMessageId = (...args) => { + claimCalls.push(args); + return true; + }; + const app = await createApp({ + agentKeyRegistry: agentKeyReg, + threadStore: makeXThreadStore(), + }); + + const held = await app.inject({ + method: 'POST', + url: '/api/callbacks/post-message', + headers: { 'x-agent-key-secret': 'valid-secret' }, + payload: { + content: audioContent('@cbk-amb-b'), + threadId: 't-cbk', + targetCats: ['cbk-amb-a'], + clientMessageId: 'cmid-p2a', + }, + }); + const body = JSON.parse(held.body); + assert.equal(body.status, 'held'); + assert.equal(body.reason, 'routing_mismatch'); + assert.equal(claimCalls.length, 0, 'claim must not fire before gate'); + assert.equal(getSynthCalls().length, 0, 'TTS provider must NOT be called on HELD — gate precedes TTS'); + }); + + test('path-2b agent-key:concurrent same-key + audio → ok+duplicate, synthCalls===1, stored===1', async () => { + const { getSynthCalls } = await initTtsSpy(ttsCacheDir); + const claimed = new Set(); + const agentKeyReg = makeAgentKeyRegistry(); + agentKeyReg.claimClientMessageId = (_akId, cmid) => { + if (claimed.has(cmid)) return false; + claimed.add(cmid); + return true; + }; + const app = await createApp({ + agentKeyRegistry: agentKeyReg, + threadStore: makeXThreadStore(), + }); + const cmid = 'cmid-concurrent-tts'; + const payload = { + content: audioContent('@cbk-amb-a'), + threadId: 't-cbk', + targetCats: ['cbk-amb-a'], + clientMessageId: cmid, + }; + const headers = { 'x-agent-key-secret': 'valid-secret' }; + + const [r1, r2] = await Promise.all([ + app.inject({ method: 'POST', url: '/api/callbacks/post-message', headers, payload }), + app.inject({ method: 'POST', url: '/api/callbacks/post-message', headers, payload }), + ]); + const b1 = JSON.parse(r1.body); + const b2 = JSON.parse(r2.body); + const statuses = [b1.status, b2.status].sort(); + assert.deepEqual(statuses, ['duplicate', 'ok'], 'one ok + one duplicate'); + assert.equal(getSynthCalls().length, 1, 'TTS called exactly once — claim deduplicates before TTS'); + const stored = messageStore.getByThread('t-cbk'); + assert.equal(stored.length, 1, 'exactly one message stored'); + }); + + // ── Path 3: assign_work (cross-thread + effectClass) ── + + test('path-3 assign_work:mismatch → HELD(DispatchProposal 不创建)', async () => { + const proposalSpy = makeProposalSpy(); + const app = await createApp({ + threadStore: makeXThreadStore(), + dispatchProposalStore: proposalSpy, + }); + // Invocation in t-source; request routes to t-target (cross-thread) + const { invocationId, callbackToken } = await registry.create('user-1', 'opus', 't-source'); + + const held = await app.inject({ + method: 'POST', + url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { + content: '@cbk-amb-b 这个任务给你', + threadId: 't-target', + targetCats: ['cbk-amb-a'], + effectClass: 'assign_work', + }, + }); + const body = JSON.parse(held.body); + assert.equal(body.status, 'held', 'assign_work with mismatch must be HELD'); + assert.equal(body.reason, 'routing_mismatch'); + assert.equal(proposalSpy.getCalls().length, 0, 'no DispatchProposal created — gate fires before intercept'); + }); + + // ── Path 4: freshness-enabled ── + + test('path-4 freshness-enabled:mismatch → HELD(deliveryCursorStore 不触及)', async () => { + const cursorSpy = makeCursorSpy(); + const app = await createApp({ + deliveryCursorStore: cursorSpy, + }); + const { invocationId, callbackToken } = await registry.create('user-1', 'opus', 't-cbk'); + + const held = await app.inject({ + method: 'POST', + url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { content: '@cbk-amb-b 给你', targetCats: ['cbk-amb-a'] }, + }); + const body = JSON.parse(held.body); + assert.equal(body.status, 'held', 'freshness-enabled path with mismatch must be HELD'); + assert.equal(body.reason, 'routing_mismatch', 'must be routing_mismatch, not freshness hold'); + assert.equal( + cursorSpy.getCalls().length, + 0, + 'deliveryCursorStore must not be touched — gate fires before freshness', + ); + }); +}); diff --git a/packages/api/test/f257-fix1-ambiguous-mention.test.js b/packages/api/test/f257-fix1-ambiguous-mention.test.js new file mode 100644 index 0000000000..dc8942912c --- /dev/null +++ b/packages/api/test/f257-fix1-ambiguous-mention.test.js @@ -0,0 +1,173 @@ +/** + * F257 修复清单 #1 — 路由解析:@ 多命中 → 拒绝路由并提示显式 handle,不猜。 + * + * 证据坐标:dev-628ea4d1。防御纵深第二道:加载层 fail-closed(见 + * f257-fix1-config-uniqueness.test.js)拦住正常路径;本层保证当冲突数据从 + * 非常规路径进入 registry(手改 catalog / 外部注入)时,路由不做 longest-first + * 静默择一,而是产出 mention_ambiguous 警告要求显式 handle。 + * + * registry 冲突构造:catRegistry.register 直接注入(绕过 config 加载校验), + * 模拟"两只猫都持有 @共名"的穿透场景。 + */ + +import './helpers/setup-cat-registry.js'; +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { catRegistry, createCatId } from '@cat-cafe/shared'; + +const { analyzeA2AMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); +const { resolveCatTarget } = await import('../dist/domains/cats/services/agents/routing/cat-target-resolver.js'); +const { AgentRouter } = await import('../dist/domains/cats/services/agents/routing/AgentRouter.js'); +const { AgentRegistry } = await import('../dist/domains/cats/services/agents/registry/AgentRegistry.js'); + +/** Minimal mocks(模式对齐 f32b-mention-parsing.test.js) */ +function createMockService(catId) { + return { + catId: createCatId(catId), + invoke: async function* (prompt) { + yield { type: 'text', catId: createCatId(catId), content: `[${catId}] ${prompt}`, timestamp: Date.now() }; + yield { type: 'done', catId: createCatId(catId), timestamp: Date.now() }; + }, + }; +} + +function createUserRouter() { + const agentRegistry = new AgentRegistry(); + agentRegistry.register('amb-cat-a', createMockService('amb-cat-a')); + agentRegistry.register('amb-cat-b', createMockService('amb-cat-b')); + return new AgentRouter({ + agentRegistry, + registry: { + create: () => ({ invocationId: 'inv-1', callbackToken: 'tok-1' }), + verify: async () => ({ ok: false, reason: 'unknown_invocation' }), + }, + messageStore: { + append: (msg) => ({ ...msg, id: 'msg-000001', threadId: msg.threadId ?? 'default' }), + getById: () => null, + getRecent: () => [], + getMentionsFor: () => [], + getByThread: () => [], + getByThreadAfter: () => [], + getByThreadBefore: () => [], + deleteByThread: () => 0, + }, + threadStore: { + get: () => null, + getParticipants: () => [], + addParticipants: () => {}, + getParticipantsWithActivity: () => [], + updateParticipantActivity: () => {}, + updateLastActive: () => {}, + }, + }); +} + +/** 注入两只共享 @共名 pattern 的测试猫(穿透加载校验的冲突态) */ +function registerConflictPair() { + const mk = (catId, patterns) => ({ + id: createCatId(catId), + name: `${catId}-name`, + displayName: `${catId}-display`, + avatar: `/avatars/${catId}.png`, + color: { primary: '#000000', secondary: '#ffffff' }, + mentionPatterns: patterns, + clientId: 'anthropic', + defaultModel: 'claude-sonnet-4-5-20250929', + mcpSupport: true, + }); + if (!catRegistry.has('amb-cat-a')) { + catRegistry.register('amb-cat-a', mk('amb-cat-a', ['@amb-cat-a', '@共名'])); + } + if (!catRegistry.has('amb-cat-b')) { + catRegistry.register('amb-cat-b', mk('amb-cat-b', ['@amb-cat-b', '@共名'])); + } +} + +registerConflictPair(); + +describe('F257 #1 修复:A2A 路由 @ 多命中拒绝', () => { + it('行首 @共名(两猫共持)→ 不路由 + mention_ambiguous 警告列出候选', () => { + const analysis = analyzeA2AMentions('@共名 请 review 这段代码', 'opus'); + assert.deepEqual(analysis.mentions, [], 'ambiguous mention must NOT resolve to any cat'); + const ambiguous = analysis.routing_warnings.filter((w) => w.kind === 'mention_ambiguous'); + assert.equal(ambiguous.length, 1); + const candidateIds = ambiguous[0].candidates.map((c) => String(c.catId)).sort(); + assert.deepEqual(candidateIds, ['amb-cat-a', 'amb-cat-b']); + // 候选必须携带可用的显式 handle(唯一 pattern),供发送方重试 + for (const candidate of ambiguous[0].candidates) { + assert.match(candidate.mention, /^@amb-cat-/); + } + }); + + it('attemptBatch 记录 ambiguous outcome(T-A typed-fact 流不丢真相)', () => { + const analysis = analyzeA2AMentions('@共名 接球', 'opus'); + const ambiguousAttempts = analysis.attemptBatch.attempts.filter((a) => a.outcome === 'ambiguous'); + assert.equal(ambiguousAttempts.length, 1); + assert.equal(ambiguousAttempts[0].token, '@共名'); + assert.equal(ambiguousAttempts[0].targetCatId, undefined, 'ambiguous attempt has no single target'); + }); + + it('唯一 pattern 照常路由(回归保护)', () => { + const analysis = analyzeA2AMentions('@amb-cat-a 接球', 'opus'); + assert.deepEqual(analysis.mentions.map(String), ['amb-cat-a']); + assert.equal(analysis.routing_warnings.length, 0); + }); + + it('同行混合:@共名 @amb-cat-b → 歧义 token 拒绝、显式 token 正常路由', () => { + const analysis = analyzeA2AMentions('@共名 @amb-cat-b 一起看', 'opus'); + assert.deepEqual(analysis.mentions.map(String), ['amb-cat-b']); + assert.equal(analysis.routing_warnings.filter((w) => w.kind === 'mention_ambiguous').length, 1); + }); +}); + +describe('F257 #1 修复:用户消息路由 @ 多命中拒绝', () => { + it('用户消息 @共名 → targetCats 为空(不 fallback 任何猫)+ mention_ambiguous 警告', async () => { + const router = createUserRouter(); + // sol F3:ambiguous-only 消息 = 用户明确想叫某只特定猫但系统无法唯一确定。 + // 解析层拒绝后不得按「无 @」语义 fallback 到 recent/default 猫——提示「未路由」 + // 与实际唤起某只猫的副作用相反,事故类仍会发生。端到端断言零 targets。 + const { targetCats, hasMentions, routing_warnings } = await router.resolveTargetsAndIntent( + '@共名 帮我看看这个问题', + 't-amb', + ); + assert.equal(hasMentions, false, 'ambiguous mention must NOT count as a resolved mention'); + assert.deepEqual(targetCats, [], 'ambiguous-only message must resolve to ZERO targets — no fallback dispatch'); + const ambiguous = routing_warnings.filter((w) => w.kind === 'mention_ambiguous'); + assert.equal(ambiguous.length, 1); + assert.deepEqual(ambiguous[0].candidates.map((c) => String(c.catId)).sort(), ['amb-cat-a', 'amb-cat-b']); + }); + + it('混合:@共名 + @amb-cat-b → 只路由显式唯一 token(歧义 token 拒绝不阻塞其余)', async () => { + const router = createUserRouter(); + const { targetCats, routing_warnings } = await router.resolveTargetsAndIntent('@共名 @amb-cat-b 一起看', 't-amb'); + assert.deepEqual(targetCats.map(String), ['amb-cat-b']); + assert.equal(routing_warnings.filter((w) => w.kind === 'mention_ambiguous').length, 1); + }); + + it('无 @ 消息 fallback 行为不受影响(回归保护:仅 ambiguous-only 抑制 fallback)', async () => { + const router = createUserRouter(); + const { targetCats } = await router.resolveTargetsAndIntent('大家好,看看这个问题', 't-amb'); + assert.ok(targetCats.length > 0, 'plain no-mention message keeps existing fallback routing'); + }); + + it('用户消息显式 handle 照常路由(回归保护)', async () => { + const router = createUserRouter(); + const { targetCats, routing_warnings } = await router.resolveTargetsAndIntent('@amb-cat-b 帮我看看', 't-amb'); + assert.ok(targetCats.map(String).includes('amb-cat-b')); + assert.equal(routing_warnings.filter((w) => w.kind === 'mention_ambiguous').length, 0); + }); +}); + +describe('F257 #1 修复:resolveCatTarget 多命中拒绝', () => { + it('resolveCatTarget(@共名) → mention_ambiguous error 而非静默取第一个', () => { + const resolved = resolveCatTarget('@共名'); + assert.ok('error' in resolved, 'ambiguous target must be an error'); + assert.equal(resolved.error.kind, 'mention_ambiguous'); + assert.deepEqual(resolved.error.candidates.map((c) => String(c.catId)).sort(), ['amb-cat-a', 'amb-cat-b']); + }); + + it('resolveCatTarget 以 catId 直接命中不受影响(catId 全局唯一)', () => { + const resolved = resolveCatTarget('amb-cat-a'); + assert.deepEqual(resolved, { ok: 'amb-cat-a' }); + }); +}); diff --git a/packages/api/test/f257-fix1-callback-ambiguity.test.js b/packages/api/test/f257-fix1-callback-ambiguity.test.js new file mode 100644 index 0000000000..e61f135336 --- /dev/null +++ b/packages/api/test/f257-fix1-callback-ambiguity.test.js @@ -0,0 +1,371 @@ +/** + * F257 修复清单 #1 增补 — callback 路径的拒绝语义完整化(sol F7 + scope 增补)。 + * + * 1. formatter:post_message 响应的人类可读 message 必须把 mention_ambiguous + * 说成「同时匹配多只猫 + 显式 handle 提示」,而不是「不存在,已跳过」。 + * 两种 auth 路径(invocation-token / agent-key)共享同一 formatter。 + * 2. routing mismatch HELD(平行 Fable kickoff 增补,operator 22:17 痛点): + * 声明 targetCats 与 content 行首 @ 解析结果不一致(content 拉进声明外的猫) + * → HELD(freshness gate 同形态),不落库不路由不静默仲裁。 + * 活体证据:kickoff 消息声明 sol、content @砚砚 → 旧逻辑静默丢弃声明目标 + * 只路由 codex(callbacks.ts content-wins 仲裁)。 + */ + +import './helpers/setup-cat-registry.js'; +import assert from 'node:assert/strict'; +import { beforeEach, describe, test } from 'node:test'; +import { catRegistry, createCatId } from '@cat-cafe/shared'; +import Fastify from 'fastify'; + +function mkConfig(catId, patterns, nickname) { + return { + id: createCatId(catId), + name: `${catId}-name`, + displayName: `${catId}-display`, + ...(nickname ? { nickname } : {}), + avatar: `/avatars/${catId}.png`, + color: { primary: '#000000', secondary: '#ffffff' }, + mentionPatterns: patterns, + clientId: 'anthropic', + defaultModel: 'claude-sonnet-4-5-20250929', + mcpSupport: true, + }; +} + +// 歧义对(callback 场景专用 token,避免与其他测试文件的注入猫冲突) +for (const [catId, patterns] of [ + ['cbk-amb-a', ['@cbk-amb-a', '@回名']], + ['cbk-amb-b', ['@cbk-amb-b', '@回名']], +]) { + if (!catRegistry.has(catId)) catRegistry.register(catId, mkConfig(catId, patterns)); +} + +function createMockSocketManager() { + const messages = []; + return { + broadcastAgentMessage(msg) { + messages.push(msg); + }, + broadcastToRoom() {}, + emitToUser() {}, + getMessages() { + return messages; + }, + }; +} + +function createMockRouter() { + const executions = []; + return { + async *routeExecution(userId, message, threadId, _userMessageId, targetCats) { + executions.push({ userId, message, threadId, targetCats }); + yield { type: 'done', catId: targetCats[0], isFinal: true, timestamp: Date.now() }; + }, + getExecutions() { + return executions; + }, + }; +} + +function createMockInvocationRecordStore() { + const records = []; + return { + create(input) { + const id = `inv-${records.length}`; + records.push({ id, ...input }); + return { outcome: 'created', invocationId: id }; + }, + update() {}, + getRecords() { + return records; + }, + }; +} + +function makeThreadStore() { + // agent-key callers pass explicit threadId → handler requires a thread store; + // createdBy must match the agent-key principal's userId (canAccessScopedThread) + const thread = { id: 't-cbk', title: 'cbk', preferredCats: [], createdBy: 'user-1' }; + return { + get: (id) => (id === 't-cbk' ? thread : null), + getParticipants: () => ['opus', 'cbk-amb-a', 'cbk-amb-b'], + addParticipants: () => {}, + getParticipantsWithActivity: () => [], + updateParticipantActivity: () => {}, + updateLastActive: () => {}, + }; +} + +function makeAgentKeyRegistry() { + return { + async verify() { + return { + ok: true, + record: { + agentKeyId: 'ak_test1', + catId: 'opus', + userId: 'user-1', + secretHash: 'x', + salt: 'y', + scope: 'user-bound', + issuedAt: Date.now(), + expiresAt: Date.now() + 86400000, + }, + }; + }, + }; +} + +describe('F257 callback ambiguity + routing mismatch', () => { + let registry; + let messageStore; + let socketManager; + let invocationRecordStore; + let mockRouter; + + beforeEach(async () => { + const { InvocationRegistry } = await import( + '../dist/domains/cats/services/agents/invocation/InvocationRegistry.js' + ); + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + registry = new InvocationRegistry(); + messageStore = new MessageStore(); + socketManager = createMockSocketManager(); + invocationRecordStore = createMockInvocationRecordStore(); + mockRouter = createMockRouter(); + }); + + async function createApp(opts = {}) { + const { callbacksRoutes } = await import('../dist/routes/callbacks.js'); + const app = Fastify(); + await app.register(callbacksRoutes, { + registry, + messageStore, + socketManager, + router: mockRouter, + invocationRecordStore, + ...opts, + }); + return app; + } + + test('invocation-token 路径:@回名(歧义)→ message 提示多只猫 + 显式 handle,不说「不存在」', async () => { + const app = await createApp(); + const { invocationId, callbackToken } = await registry.create('user-1', 'opus', 't-cbk'); + const response = await app.inject({ + method: 'POST', + url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { content: '@回名 请接球' }, + }); + assert.equal(response.statusCode, 200); + const body = JSON.parse(response.body); + assert.match(body.message, /同时匹配多只猫/, 'human-readable message must state ambiguity'); + assert.match(body.message, /@cbk-amb-a|@cbk-amb-b/, 'must offer explicit handles'); + assert.doesNotMatch(body.message, /不存在/, 'must NOT misreport ambiguity as not-found'); + assert.equal(mockRouter.getExecutions().length, 0, 'ambiguous mention must not dispatch'); + assert.equal(invocationRecordStore.getRecords().length, 0); + }); + + test('agent-key 路径:同 formatter 覆盖(双 auth 路径一致)', async () => { + const app = await createApp({ agentKeyRegistry: makeAgentKeyRegistry(), threadStore: makeThreadStore() }); + const response = await app.inject({ + method: 'POST', + url: '/api/callbacks/post-message', + headers: { 'x-agent-key-secret': 'valid-secret' }, + payload: { content: '@回名 请接球', threadId: 't-cbk' }, + }); + assert.equal(response.statusCode, 200); + const body = JSON.parse(response.body); + assert.match(body.message, /同时匹配多只猫/); + assert.doesNotMatch(body.message, /不存在/); + assert.equal(mockRouter.getExecutions().length, 0); + }); + + test('routing mismatch:声明 targetCats=[cbk-amb-a] + content 行首 @cbk-amb-b → HELD 不静默仲裁', async () => { + const app = await createApp({ agentKeyRegistry: makeAgentKeyRegistry(), threadStore: makeThreadStore() }); + const response = await app.inject({ + method: 'POST', + url: '/api/callbacks/post-message', + headers: { 'x-agent-key-secret': 'valid-secret' }, + payload: { content: '@cbk-amb-b 这个给你', threadId: 't-cbk', targetCats: ['cbk-amb-a'] }, + }); + assert.equal(response.statusCode, 200); + const body = JSON.parse(response.body); + assert.equal(body.status, 'held', 'declared/parsed mismatch must be HELD, not silently arbitrated'); + assert.equal(body.reason, 'routing_mismatch'); + assert.deepEqual(body.unexpectedTargets, ['cbk-amb-b']); + assert.equal(mockRouter.getExecutions().length, 0, 'no dispatch on mismatch'); + assert.equal(messageStore.getByThread('t-cbk').length, 0, 'held message must not be stored'); + }); + + test('声明 targetCats + content 解析目标是声明子集 → 正常(无 mismatch)', async () => { + const app = await createApp({ agentKeyRegistry: makeAgentKeyRegistry(), threadStore: makeThreadStore() }); + const response = await app.inject({ + method: 'POST', + url: '/api/callbacks/post-message', + headers: { 'x-agent-key-secret': 'valid-secret' }, + payload: { content: '@cbk-amb-a 这个给你', threadId: 't-cbk', targetCats: ['cbk-amb-a', 'cbk-amb-b'] }, + }); + assert.equal(response.statusCode, 200); + const body = JSON.parse(response.body); + assert.notEqual(body.status, 'held'); + }); + + // ── sol R2 P1-1: HELD 必须零副作用——claim/consume 在 gate 之后 ── + + test('P1-1 agent-key:HELD 后同 clientMessageId 重试必须成功,不被判 duplicate', async () => { + const app = await createApp({ agentKeyRegistry: makeAgentKeyRegistry(), threadStore: makeThreadStore() }); + const clientMessageId = 'cmid-retry-after-held'; + + // Step 1: mismatch → HELD(当前实现在此已 claim 了 clientMessageId) + const held = await app.inject({ + method: 'POST', + url: '/api/callbacks/post-message', + headers: { 'x-agent-key-secret': 'valid-secret' }, + payload: { content: '@cbk-amb-b 给你', threadId: 't-cbk', targetCats: ['cbk-amb-a'], clientMessageId }, + }); + assert.equal(JSON.parse(held.body).status, 'held', 'precondition: first attempt must be HELD'); + + // Step 2: 修正 content 后同 clientMessageId 重试 → 必须成功 + const retry = await app.inject({ + method: 'POST', + url: '/api/callbacks/post-message', + headers: { 'x-agent-key-secret': 'valid-secret' }, + payload: { content: '@cbk-amb-a 给你', threadId: 't-cbk', targetCats: ['cbk-amb-a'], clientMessageId }, + }); + const retryBody = JSON.parse(retry.body); + assert.notEqual(retryBody.status, 'duplicate', 'retry after HELD must NOT be treated as duplicate'); + assert.notEqual(retryBody.status, 'held', 'corrected retry must not be HELD again'); + }); + + test('P1-1 invocation-token:HELD 后同 clientMessageId 重试必须成功,不被判 duplicate', async () => { + const app = await createApp(); + const { invocationId, callbackToken } = await registry.create('user-1', 'opus', 't-cbk'); + const clientMessageId = 'cmid-inv-retry-after-held'; + + // Step 1: mismatch → HELD + const held = await app.inject({ + method: 'POST', + url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { content: '@cbk-amb-b 给你', targetCats: ['cbk-amb-a'], clientMessageId }, + }); + assert.equal(JSON.parse(held.body).status, 'held', 'precondition: first attempt must be HELD'); + + // Step 2: 修正 content 后同 clientMessageId 重试 + const retry = await app.inject({ + method: 'POST', + url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { content: '@cbk-amb-a 给你', targetCats: ['cbk-amb-a'], clientMessageId }, + }); + const retryBody = JSON.parse(retry.body); + assert.notEqual(retryBody.status, 'duplicate', 'retry after HELD must NOT be treated as duplicate'); + }); + + // ── sol R2 P1-2: 声明存在性与可路由性分离 ── + + test('P1-2:声明全部无效 targetCats + content @real-cat → HELD(gate 不 fail-open)', async () => { + const app = await createApp({ agentKeyRegistry: makeAgentKeyRegistry(), threadStore: makeThreadStore() }); + const response = await app.inject({ + method: 'POST', + url: '/api/callbacks/post-message', + headers: { 'x-agent-key-secret': 'valid-secret' }, + payload: { content: '@cbk-amb-a 接球', threadId: 't-cbk', targetCats: ['nonexistent-cat-xyz'] }, + }); + const body = JSON.parse(response.body); + assert.equal(body.status, 'held', 'all-invalid declared targets + content @mention = HELD, not fail-open'); + assert.equal(body.reason, 'routing_mismatch'); + assert.deepEqual(body.unexpectedTargets, ['cbk-amb-a']); + }); + + test('P1-2:声明全部 disabled + content @real-cat → HELD', async () => { + // 注册一只 disabled 猫用于测试 + if (!catRegistry.has('cbk-disabled')) { + catRegistry.register('cbk-disabled', mkConfig('cbk-disabled', ['@cbk-disabled'])); + } + const { getRoster } = await import('../dist/config/cat-config-loader.js'); + const roster = getRoster(); + const savedEntry = roster['cbk-disabled']; + roster['cbk-disabled'] = { ...savedEntry, available: false }; + try { + const app = await createApp({ agentKeyRegistry: makeAgentKeyRegistry(), threadStore: makeThreadStore() }); + const response = await app.inject({ + method: 'POST', + url: '/api/callbacks/post-message', + headers: { 'x-agent-key-secret': 'valid-secret' }, + payload: { content: '@cbk-amb-a 接球', threadId: 't-cbk', targetCats: ['cbk-disabled'] }, + }); + const body = JSON.parse(response.body); + assert.equal(body.status, 'held', 'all-disabled declared targets + content @mention = HELD'); + assert.equal(body.reason, 'routing_mismatch'); + } finally { + if (savedEntry) roster['cbk-disabled'] = savedEntry; + else delete roster['cbk-disabled']; + } + }); + + // ── sol R2 P1-2 补缺:声明三类无效之 ambiguous-alias ── + + test('P1-2:声明歧义别名 targetCats + content @real-cat → HELD(三类无效声明全覆盖)', async () => { + const app = await createApp({ agentKeyRegistry: makeAgentKeyRegistry(), threadStore: makeThreadStore() }); + const response = await app.inject({ + method: 'POST', + url: '/api/callbacks/post-message', + headers: { 'x-agent-key-secret': 'valid-secret' }, + // '回名' is ambiguous — matches both cbk-amb-a and cbk-amb-b (L36-41) + payload: { content: '@cbk-amb-a 接球', threadId: 't-cbk', targetCats: ['回名'] }, + }); + const body = JSON.parse(response.body); + assert.equal(body.status, 'held', 'ambiguous-alias declared target + content @mention = HELD'); + assert.equal(body.reason, 'routing_mismatch'); + assert.deepEqual(body.unexpectedTargets, ['cbk-amb-a']); + }); + + // ── sol R2 P1-1 补缺:HELD 时 buffered rich block 不被 consume ── + + test('P1-1 invocation-token:HELD 时 buffered rich block 不被 consume(buffer 存活验证)', async () => { + const { getRichBlockBuffer } = await import('../dist/domains/cats/services/agents/invocation/RichBlockBuffer.js'); + const app = await createApp(); + const threadId = 't-buffer-held'; + const { invocationId, callbackToken } = await registry.create('user-1', 'opus', threadId); + + // Pre: add a rich block to the buffer for this invocation + const testBlock = { + kind: 'file', + v: 1, + id: `blk-${invocationId}`, + name: 'test.txt', + url: 'https://example.com/test.txt', + }; + getRichBlockBuffer().add(threadId, 'opus', testBlock, invocationId); + + // Step 1: send mismatch → HELD (consume at L1614 must NOT fire) + const held = await app.inject({ + method: 'POST', + url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { content: '@cbk-amb-b 给你', targetCats: ['cbk-amb-a'] }, + }); + assert.equal(JSON.parse(held.body).status, 'held', 'precondition: must be HELD'); + + // Step 2: verify buffer NOT consumed — blocks still available + const surviving = getRichBlockBuffer().consume(threadId, 'opus', invocationId); + assert.ok(surviving.length > 0, 'buffered rich block must survive HELD — consume must not fire before gate'); + assert.equal(surviving[0].id, testBlock.id, 'the exact block must be the one we buffered'); + }); + + test('无声明 targetCats:content 唯一 @ 正常路由(无 mismatch 语义,回归保护)', async () => { + const app = await createApp({ agentKeyRegistry: makeAgentKeyRegistry(), threadStore: makeThreadStore() }); + const response = await app.inject({ + method: 'POST', + url: '/api/callbacks/post-message', + headers: { 'x-agent-key-secret': 'valid-secret' }, + payload: { content: '@cbk-amb-a 这个给你', threadId: 't-cbk' }, + }); + assert.equal(response.statusCode, 200); + const body = JSON.parse(response.body); + assert.notEqual(body.status, 'held'); + }); +}); diff --git a/packages/api/test/f257-fix1-config-uniqueness.test.js b/packages/api/test/f257-fix1-config-uniqueness.test.js new file mode 100644 index 0000000000..458ebfcc43 --- /dev/null +++ b/packages/api/test/f257-fix1-config-uniqueness.test.js @@ -0,0 +1,312 @@ +/** + * F257 修复清单 #1 — 昵称唯一性与模糊 @ fail-closed(config 层) + * + * 证据坐标:dev-628ea4d1(@砚砚 确定性投错 codex;宪宪×3/砚砚×5/烁烁×2 活体冲突)。 + * 契约: + * 1. mentionPatterns 跨猫冲突 → toAllCatConfigs 抛错(fail-closed,启动拒绝) + * 2. nickname 跨猫冲突 → 不阻断加载(现网存量冲突需可启动),结构化告警可收集 + * 3. nickname 从家族(breed)层移到 per-cat:非 default variant 不再继承 breed.nickname + * 4. 写入层(runtime-cat-catalog)nickname 增量唯一:新写入与他猫冲突 → 拒; + * 清空/收敛操作永远放行(防止存量多冲突陷入无法单步收敛的死锁) + */ + +import './helpers/setup-cat-registry.js'; +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, describe, it } from 'node:test'; + +const { toAllCatConfigs } = await import('../dist/config/cat-config-loader.js'); +const { collectCrossCatConflicts } = await import('../dist/config/cat-uniqueness.js'); +const { createRuntimeCat, updateRuntimeCat } = await import('../dist/config/runtime-cat-catalog.js'); + +const tempDirs = []; +after(() => { + for (const dir of tempDirs) { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } + } +}); + +/** 最小可加载 breed;overrides 覆盖顶层字段 */ +function makeBreed(id, catId, overrides = {}) { + return { + id, + catId, + name: `${id}-name`, + displayName: `${id}-display`, + avatar: `/avatars/${id}.png`, + color: { primary: '#000000', secondary: '#ffffff' }, + mentionPatterns: [`@${catId}`], + roleDescription: 'test cat', + defaultVariantId: `${id}-default`, + variants: [ + { + id: `${id}-default`, + clientId: 'anthropic', + defaultModel: 'claude-sonnet-4-5-20250929', + mcpSupport: true, + cli: { command: 'claude', outputFormat: 'stream-json' }, + personality: 'test', + }, + ], + ...overrides, + }; +} + +function makeConfig(breeds) { + return { version: 1, breeds }; +} + +describe('F257 #1 修复:mentionPatterns 跨猫冲突 fail-closed', () => { + it('两只猫共享同一 mention pattern → toAllCatConfigs 抛错(启动拒绝)', () => { + const config = makeConfig([ + makeBreed('breed-a', 'cata', { mentionPatterns: ['@cata', '@砚砚'] }), + makeBreed('breed-b', 'catb', { mentionPatterns: ['@catb', '@砚砚'] }), + ]); + assert.throws( + () => toAllCatConfigs(config), + (err) => /@砚砚/.test(err.message) && /cata/.test(err.message) && /catb/.test(err.message), + 'conflict error should name the pattern and both holder cats', + ); + }); + + it('pattern 冲突判定大小写不敏感(@Shared vs @shared)', () => { + const config = makeConfig([ + makeBreed('breed-a', 'cata', { mentionPatterns: ['@Shared'] }), + makeBreed('breed-b', 'catb', { mentionPatterns: ['@shared'] }), + ]); + assert.throws(() => toAllCatConfigs(config), /shared/i); + }); + + it('同一只猫自身 pattern 重复(breed 与 variant 同值)不算跨猫冲突', () => { + const config = makeConfig([ + makeBreed('breed-a', 'cata', { mentionPatterns: ['@cata', '@cata'] }), + makeBreed('breed-b', 'catb'), + ]); + const all = toAllCatConfigs(config); + assert.ok(all.cata); + assert.ok(all.catb); + }); +}); + +describe('F257 #1 修复:nickname 跨猫冲突 = 告警不阻断(存量兼容)', () => { + it('两只猫同 nickname → 加载成功 + collectCrossCatConflicts 报告冲突', () => { + const config = makeConfig([ + makeBreed('breed-a', 'cata', { nickname: '砚砚' }), + makeBreed('breed-b', 'catb', { nickname: '砚砚' }), + ]); + const all = toAllCatConfigs(config); // 不抛:现网存量冲突(砚砚×5)必须仍可启动 + const { nicknameConflicts, patternConflicts } = collectCrossCatConflicts(all); + assert.equal(patternConflicts.length, 0); + assert.equal(nicknameConflicts.length, 1); + assert.equal(nicknameConflicts[0].nickname, '砚砚'); + assert.deepEqual([...nicknameConflicts[0].holders].sort(), ['cata', 'catb']); + }); + + it('nickname 唯一时 conflicts 为空', () => { + const config = makeConfig([ + makeBreed('breed-a', 'cata', { nickname: '宪宪' }), + makeBreed('breed-b', 'catb', { nickname: '砚砚' }), + ]); + const { nicknameConflicts } = collectCrossCatConflicts(toAllCatConfigs(config)); + assert.equal(nicknameConflicts.length, 0); + }); +}); + +describe('F257 #1 修复:nickname 从家族层移到 per-cat(继承语义收窄)', () => { + function multiVariantConfig() { + return makeConfig([ + makeBreed('ragdoll', 'opus', { + nickname: '宪宪', + variants: [ + { + id: 'ragdoll-default', + clientId: 'anthropic', + defaultModel: 'claude-opus-4-6', + mcpSupport: true, + cli: { command: 'claude', outputFormat: 'stream-json' }, + personality: 'test', + }, + { + id: 'ragdoll-fable', + catId: 'fable-5', + clientId: 'anthropic', + defaultModel: 'claude-fable-5', + mcpSupport: true, + cli: { command: 'claude', outputFormat: 'stream-json' }, + personality: 'test', + }, + { + id: 'ragdoll-named', + catId: 'named-cat', + nickname: '专属名', + clientId: 'anthropic', + defaultModel: 'claude-sonnet-4-5-20250929', + mcpSupport: true, + cli: { command: 'claude', outputFormat: 'stream-json' }, + personality: 'test', + }, + ], + defaultVariantId: 'ragdoll-default', + }), + ]); + } + + it('default variant 仍继承 breed.nickname(单猫家族行为不变)', () => { + const all = toAllCatConfigs(multiVariantConfig()); + assert.equal(all.opus.nickname, '宪宪'); + }); + + it('非 default variant 不再继承 breed.nickname(dev-628ea4d1 根因:家族层昵称复制到每只猫)', () => { + const all = toAllCatConfigs(multiVariantConfig()); + assert.equal(all['fable-5'].nickname, undefined, 'non-default variant must NOT inherit family nickname'); + }); + + it('variant 显式 nickname 仍然生效(per-cat 实例声明)', () => { + const all = toAllCatConfigs(multiVariantConfig()); + assert.equal(all['named-cat'].nickname, '专属名'); + }); + + it('variant nickname=null 表示显式无昵称(#1090 语义保持)', () => { + const config = makeConfig([ + makeBreed('breed-a', 'cata', { + nickname: '宪宪', + variants: [ + { + id: 'breed-a-default', + nickname: null, + clientId: 'anthropic', + defaultModel: 'claude-sonnet-4-5-20250929', + mcpSupport: true, + cli: { command: 'claude', outputFormat: 'stream-json' }, + personality: 'test', + }, + ], + defaultVariantId: 'breed-a-default', + }), + ]); + const all = toAllCatConfigs(config); + assert.equal(all.cata.nickname, undefined); + }); +}); + +describe('F257 #1 修复(sol F1):roleTemplate 派生 cat 写入冲突 fail-closed', () => { + it('同模板连续创建两只猫:第二只因 pattern 冲突被明确拒绝,不生成静默碰撞', () => { + // FirstRunQuest 形状:从 roleTemplate 菜单数据派生 input(name → @name pattern) + const projectRoot = mkdtempSync(join(tmpdir(), 'f257-roletpl-')); + tempDirs.push(projectRoot); + const empty = makeConfig([makeBreed('seed-breed', 'seed-cat')]); + writeFileSync(join(projectRoot, 'cat-template.json'), JSON.stringify(empty, null, 2)); + mkdirSync(join(projectRoot, '.cat-cafe'), { recursive: true }); + writeFileSync(join(projectRoot, '.cat-cafe', 'cat-catalog.json'), JSON.stringify(empty, null, 2)); + + const fromTemplate = (catId) => ({ + catId, + name: '布偶猫', + displayName: '布偶猫', + avatar: '/avatars/opus.png', + color: { primary: '#9B7EBD', secondary: '#E8DFF5' }, + mentionPatterns: ['@布偶猫'], + roleDescription: '主架构师', + clientId: 'anthropic', + defaultModel: 'claude-opus-4-6', + mcpSupport: true, + cli: { command: 'claude', outputFormat: 'stream-json' }, + }); + + createRuntimeCat(projectRoot, fromTemplate('ragdoll-one')); + assert.throws( + () => createRuntimeCat(projectRoot, fromTemplate('ragdoll-two')), + /@布偶猫/, + 'second cat from the same template must be explicitly rejected (fail-closed), never silently colliding', + ); + }); +}); + +describe('F257 #1 修复:写入层 nickname 增量唯一(fail-closed 新增冲突,放行收敛)', () => { + function makeProject(breeds) { + const projectRoot = mkdtempSync(join(tmpdir(), 'f257-fix1-')); + tempDirs.push(projectRoot); + const config = makeConfig(breeds); + writeFileSync(join(projectRoot, 'cat-template.json'), JSON.stringify(config, null, 2)); + mkdirSync(join(projectRoot, '.cat-cafe'), { recursive: true }); + writeFileSync(join(projectRoot, '.cat-cafe', 'cat-catalog.json'), JSON.stringify(config, null, 2)); + return projectRoot; + } + + function makeRuntimeInput(catId, nickname) { + return { + catId, + name: `${catId}-name`, + displayName: `${catId}-display`, + ...(nickname ? { nickname } : {}), + avatar: `/avatars/${catId}.png`, + color: { primary: '#000000', secondary: '#ffffff' }, + mentionPatterns: [`@${catId}`], + roleDescription: 'runtime test cat', + clientId: 'anthropic', + defaultModel: 'claude-sonnet-4-5-20250929', + mcpSupport: true, + cli: { command: 'claude', outputFormat: 'stream-json' }, + }; + } + + it('createRuntimeCat:nickname 已被他猫持有 → 拒绝写入', () => { + const projectRoot = makeProject([makeBreed('breed-a', 'cata', { nickname: '宪宪' })]); + assert.throws( + () => createRuntimeCat(projectRoot, makeRuntimeInput('cat-new1', '宪宪')), + /宪宪.*cata|cata.*宪宪/s, + 'creating a cat with an already-held nickname must fail-closed', + ); + }); + + it('createRuntimeCat:nickname 未被持有 → 正常创建', () => { + const projectRoot = makeProject([makeBreed('breed-a', 'cata', { nickname: '宪宪' })]); + const updated = createRuntimeCat(projectRoot, makeRuntimeInput('cat-new2', '新名')); + assert.equal(toAllCatConfigs(updated)['cat-new2'].nickname, '新名'); + }); + + it('updateRuntimeCat:改 nickname 撞他猫 → 拒绝写入', () => { + const projectRoot = makeProject([ + makeBreed('breed-a', 'cata', { nickname: '宪宪' }), + makeBreed('breed-b', 'catb', { nickname: '砚砚' }), + ]); + assert.throws(() => updateRuntimeCat(projectRoot, 'catb', { nickname: '宪宪' }), /宪宪.*cata|cata.*宪宪/s); + }); + + it('updateRuntimeCat:清空 nickname 永远放行——即使 catalog 中仍有其他猫互相冲突(防收敛死锁)', () => { + const projectRoot = makeProject([ + makeBreed('breed-a', 'cata', { nickname: '砚砚' }), + makeBreed('breed-b', 'catb', { nickname: '砚砚' }), + makeBreed('breed-c', 'catc', { nickname: '砚砚' }), + ]); + // 存量三重冲突(模拟现网砚砚×5):单步清掉一只必须成功,否则 operator 无法逐步收敛 + const updated = updateRuntimeCat(projectRoot, 'cata', { nickname: '' }); + const all = toAllCatConfigs(updated); + assert.equal(all.cata.nickname, undefined); + assert.equal(all.catb.nickname, '砚砚'); + }); + + it('updateRuntimeCat:nickname 不变的幂等写入放行', () => { + const projectRoot = makeProject([ + makeBreed('breed-a', 'cata', { nickname: '砚砚' }), + makeBreed('breed-b', 'catb', { nickname: '砚砚' }), + ]); + const updated = updateRuntimeCat(projectRoot, 'cata', { nickname: '砚砚' }); + assert.equal(toAllCatConfigs(updated).cata.nickname, '砚砚'); + }); + + it('updateRuntimeCat:改成未被持有的新 nickname 放行', () => { + const projectRoot = makeProject([ + makeBreed('breed-a', 'cata', { nickname: '砚砚' }), + makeBreed('breed-b', 'catb', { nickname: '砚砚' }), + ]); + const updated = updateRuntimeCat(projectRoot, 'catb', { nickname: '小砚' }); + assert.equal(toAllCatConfigs(updated).catb.nickname, '小砚'); + }); +}); diff --git a/packages/api/test/f257-fix1-nickname-ambiguity.test.js b/packages/api/test/f257-fix1-nickname-ambiguity.test.js new file mode 100644 index 0000000000..0109e1f31d --- /dev/null +++ b/packages/api/test/f257-fix1-nickname-ambiguity.test.js @@ -0,0 +1,121 @@ +/** + * F257 修复清单 #1 — 路由歧义判定的 nickname 维度(sol review F2/F5 修复)。 + * + * 失败模式(sol 活体复现):砚砚是 5 只猫的 nickname,但 @砚砚 pattern 只在 + * codex 的 mentionPatterns 里 → pattern 视图唯一归属 → 零 warning 路由 codex。 + * 意图模型上 @昵称 就是「叫那只昵称为 X 的猫」——nickname 多持有即歧义。 + * + * 契约(token → holders 统一视图,三源合并): + * holders(@token) = pattern 持有者 ∪ nickname 持有者 ∪ canonical @catId + * - 多 holder → ambiguous(拒绝路由,即使 pattern 侧唯一归属) + * - 唯一 nickname 持有者且无 pattern 争夺 → @昵称 可路由(身份即 handle) + * - canonical @catId 是保留命名空间:每猫永远有一个可被同一 parser 识别的 + * 唯一显式 handle;candidates 推荐的 mention 必须回喂 parser 可 resolved + */ + +import './helpers/setup-cat-registry.js'; +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { catRegistry, createCatId } from '@cat-cafe/shared'; + +const { analyzeA2AMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); +const { resolveCatTarget } = await import('../dist/domains/cats/services/agents/routing/cat-target-resolver.js'); + +function mkConfig(catId, patterns, nickname) { + return { + id: createCatId(catId), + name: `${catId}-name`, + displayName: `${catId}-display`, + ...(nickname ? { nickname } : {}), + avatar: `/avatars/${catId}.png`, + color: { primary: '#000000', secondary: '#ffffff' }, + mentionPatterns: patterns, + clientId: 'anthropic', + defaultModel: 'claude-sonnet-4-5-20250929', + mcpSupport: true, + }; +} + +// 活体 catalog 形状(sol F2 fixture 要求):多猫共用 nickname、仅一猫显式持有 @nickname pattern +function registerLiveShapeCats() { + const cats = [ + ['liv-a', ['@liv-a', '@砚测'], '砚测'], // codex 形状:pattern + nickname 双持 + ['liv-b', ['@liv-b'], '砚测'], // sol 形状:仅 nickname + ['liv-c', ['@liv-c'], '砚测'], // spark 形状:仅 nickname + ['liv-d', ['@liv-d'], '独测'], // 唯一 nickname 持有者(@独测 不在 patterns) + ['liv-e', ['@共测2'], null], // 无唯一 pattern(共享 @共测2)——candidates 必须回退 canonical + ['liv-f', ['@共测2'], null], + ]; + for (const [catId, patterns, nickname] of cats) { + if (!catRegistry.has(catId)) { + catRegistry.register(catId, mkConfig(catId, patterns, nickname)); + } + } +} + +registerLiveShapeCats(); + +describe('F257 #1 修复(sol F2):nickname 多持有 = 路由歧义,即使 pattern 唯一归属', () => { + it('活体形状:@砚测(pattern 仅 liv-a 持有,nickname 三猫持有)→ 拒绝路由 + 三候选', () => { + const analysis = analyzeA2AMentions('@砚测 请 review 这段代码', 'opus'); + assert.deepEqual(analysis.mentions, [], 'nickname collision must NOT route to the sole pattern holder'); + const ambiguous = analysis.routing_warnings.filter((w) => w.kind === 'mention_ambiguous'); + assert.equal(ambiguous.length, 1); + assert.deepEqual( + ambiguous[0].candidates.map((c) => String(c.catId)).sort(), + ['liv-a', 'liv-b', 'liv-c'], + 'candidates must include ALL nickname holders, not just pattern holders', + ); + }); + + it('resolveCatTarget(@砚测) → mention_ambiguous(显式 target 入口同一视图)', () => { + const resolved = resolveCatTarget('@砚测'); + assert.ok('error' in resolved, 'nickname collision must be an error'); + assert.equal(resolved.error.kind, 'mention_ambiguous'); + assert.deepEqual(resolved.error.candidates.map((c) => String(c.catId)).sort(), ['liv-a', 'liv-b', 'liv-c']); + }); + + it('唯一 nickname 持有者:@独测(不在 patterns)→ 正常路由到 liv-d(身份即 handle)', () => { + const analysis = analyzeA2AMentions('@独测 接球', 'opus'); + assert.deepEqual(analysis.mentions.map(String), ['liv-d']); + assert.equal(analysis.routing_warnings.length, 0); + }); + + it('显式唯一 pattern 不受同猫 nickname 影响:@liv-a 照常路由(回归保护)', () => { + const analysis = analyzeA2AMentions('@liv-a 接球', 'opus'); + assert.deepEqual(analysis.mentions.map(String), ['liv-a']); + assert.equal(analysis.routing_warnings.length, 0); + }); +}); + +describe('F257 #1 修复(sol F5):candidates 推荐 handle 必须可被同一 parser 路由', () => { + it('无唯一 pattern 的猫(共享 @共测2)→ candidates 回退 canonical @catId', () => { + const analysis = analyzeA2AMentions('@共测2 一起看', 'opus'); + assert.deepEqual(analysis.mentions, []); + const ambiguous = analysis.routing_warnings.filter((w) => w.kind === 'mention_ambiguous'); + assert.equal(ambiguous.length, 1); + const mentionByCat = new Map(ambiguous[0].candidates.map((c) => [String(c.catId), c.mention])); + assert.equal(mentionByCat.get('liv-e'), '@liv-e', 'canonical @catId must be the fallback handle'); + assert.equal(mentionByCat.get('liv-f'), '@liv-f'); + }); + + it('推荐的每个 candidate.mention 回喂 A2A parser 都能 resolved 到对应猫', () => { + const analysis = analyzeA2AMentions('@共测2 一起看', 'opus'); + const ambiguous = analysis.routing_warnings.filter((w) => w.kind === 'mention_ambiguous'); + for (const candidate of ambiguous[0].candidates) { + const retry = analyzeA2AMentions(`${candidate.mention} 重试`, 'opus'); + assert.deepEqual( + retry.mentions.map(String), + [String(candidate.catId)], + `recommended handle ${candidate.mention} must actually route to ${candidate.catId}`, + ); + } + }); + + it('canonical @catId 直接可路由(保留命名空间,无需出现在 mentionPatterns)', () => { + // liv-e 的 patterns 只有共享的 @共测2 —— @liv-e 是合成 canonical + const analysis = analyzeA2AMentions('@liv-e 接球', 'opus'); + assert.deepEqual(analysis.mentions.map(String), ['liv-e']); + assert.equal(analysis.routing_warnings.length, 0); + }); +}); diff --git a/packages/api/test/f257-l0-manifest-cli.test.js b/packages/api/test/f257-l0-manifest-cli.test.js new file mode 100644 index 0000000000..c4955c46bb --- /dev/null +++ b/packages/api/test/f257-l0-manifest-cli.test.js @@ -0,0 +1,102 @@ +/** + * F257 #2 (2b R2 P2-1) — REAL L0 compiler ↔ manifest contract (no fake spawn). + * + * The unit tests use a fake spawn that assumes --manifest-out writes correct JSON; this + * guards the actual producer so it can't silently stop writing or diverge while the unit + * tests stay green. Proves: compileL0WithManifest emits exactly L1-L7 and each manifest + * content appears byte-for-byte in the compiled prompt, and the CLI's --manifest-out is + * orthogonal to --out (file + stdout modes). + * + * Test-data isolation (2b R2 P2-2): the compiler is ALWAYS pointed at a dedicated EMPTY + * temp profile dir (--profile-dir / options.profileDir), so it never reads real user + * capsule/primer data; every temp dir (profile + compile outputs) is tracked and removed. + */ + +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { after, before, describe, test } from 'node:test'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const testDir = dirname(fileURLToPath(import.meta.url)); +// packages/api/test → up 3 → repo root → scripts/compile-system-prompt-l0.mjs +const scriptPath = resolve(testDir, '..', '..', '..', 'scripts', 'compile-system-prompt-l0.mjs'); +const L_IDS = ['L1', 'L2', 'L3', 'L4', 'L5', 'L6', 'L7']; +const CAT = 'opus'; + +const tmpDirs = []; +function mkTmp(prefix) { + const d = mkdtempSync(join(tmpdir(), prefix)); + tmpDirs.push(d); + return d; +} + +describe('F257 #2 — real compiler manifest contract (2b R2 P2-1)', () => { + let mjs; + let profileDir; + + before(async () => { + mjs = await import(pathToFileURL(scriptPath).href); + // Dedicated EMPTY profile dir — isolates the compile from any real user profile data. + profileDir = mkTmp('l0-profile-'); + }); + + after(() => { + for (const d of tmpDirs.splice(0)) { + try { + rmSync(d, { recursive: true, force: true }); + } catch { + /* best-effort temp cleanup */ + } + } + }); + + test('compileL0WithManifest: exactly L1-L7, each content byte-for-byte in the compiled prompt', async () => { + const { compiled, lSegments } = await mjs.compileL0WithManifest({ catId: CAT, profileDir }); + assert.deepEqual( + lSegments.map((s) => s.id), + L_IDS, + 'manifest is exactly L1-L7 in canonical order', + ); + for (const seg of lSegments) { + assert.ok(seg.content.trim().length > 0, `${seg.id} non-blank`); + assert.ok(compiled.includes(seg.content), `${seg.id} content appears byte-for-byte in the compiled prompt`); + } + }); + + test('CLI --manifest-out is orthogonal to --out (file mode + stdout mode)', () => { + const dir = mkTmp('l0-cli-'); + + // File mode: --out writes the prompt, --manifest-out writes the manifest. + const outPath = join(dir, 'prompt.md'); + const mPath = join(dir, 'manifest.json'); + execFileSync( + process.execPath, + [scriptPath, '--cat', CAT, '--profile-dir', profileDir, '--out', outPath, '--manifest-out', mPath], + { stdio: ['ignore', 'ignore', 'inherit'] }, + ); + const fileManifest = JSON.parse(readFileSync(mPath, 'utf8')); + assert.deepEqual( + fileManifest.map((s) => s.id), + L_IDS, + ); + const filePrompt = readFileSync(outPath, 'utf8'); + for (const seg of fileManifest) assert.ok(filePrompt.includes(seg.content), `${seg.id} in --out prompt`); + + // Stdout mode: no --out → prompt on stdout; --manifest-out still writes the manifest. + const mPath2 = join(dir, 'manifest2.json'); + const stdout = execFileSync( + process.execPath, + [scriptPath, '--cat', CAT, '--profile-dir', profileDir, '--manifest-out', mPath2], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] }, + ); + const stdoutManifest = JSON.parse(readFileSync(mPath2, 'utf8')); + assert.deepEqual( + stdoutManifest.map((s) => s.id), + L_IDS, + ); + for (const seg of stdoutManifest) assert.ok(stdout.includes(seg.content), `${seg.id} in stdout prompt`); + }); +}); diff --git a/packages/api/test/f257-l0-manifest.test.js b/packages/api/test/f257-l0-manifest.test.js new file mode 100644 index 0000000000..a09a996fd7 --- /dev/null +++ b/packages/api/test/f257-l0-manifest.test.js @@ -0,0 +1,120 @@ +/** + * F257 #2 — L0 compiler manifest boundary (foundation). + * + * Proves getL0ManifestViaSubprocess() sources the per-segment L1-L7 manifest from the + * SAME subprocess compile that produces the delivered prompt string, riding the SAME + * cache/generation lifecycle (lockstep with l0Cache), and fails open (empty manifest, + * never a throw that would break the fail-closed string compile). Uses a fake spawn + * that writes the --manifest-out file exactly as the real compiler CLI does. + */ + +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { + clearL0Cache, + compileL0ViaSubprocess, + getL0ManifestViaSubprocess, +} from '../dist/domains/cats/services/agents/providers/l0-compiler.js'; + +function makeRoot() { + const root = mkdtempSync(join(tmpdir(), 'l0-manifest-')); + mkdirSync(join(root, 'scripts'), { recursive: true }); + writeFileSync(join(root, 'scripts', 'compile-system-prompt-l0.mjs'), '// fake'); + return root; +} + +/** + * Fake spawn that mimics the real CLI: writes the compiled string to --out (or + * emits it on stdout), and writes the JSON manifest to --manifest-out. + */ +function buildManifestSpawn({ compiled = 'COMPILED-L0', manifest = [], exitCode = 0 }) { + const fn = function fakeSpawn(cmd, args, opts) { + fn.calls.push({ cmd, args, opts }); + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + setImmediate(() => { + const outIdx = args.indexOf('--out'); + if (outIdx >= 0 && args[outIdx + 1]) writeFileSync(args[outIdx + 1], compiled, 'utf8'); + const mIdx = args.indexOf('--manifest-out'); + if (mIdx >= 0 && args[mIdx + 1] && manifest !== null) { + writeFileSync(args[mIdx + 1], JSON.stringify(manifest), 'utf8'); + } + if (outIdx < 0) child.stdout.emit('data', Buffer.from(compiled)); + child.emit('close', exitCode); + }); + return child; + }; + fn.calls = []; + return fn; +} + +const L_MANIFEST = [ + { id: 'L1', content: '你不是一个孤立的工具' }, + { id: 'L2', content: '客观性 carry-over' }, + { id: 'L3', content: '传球三选一' }, + { id: 'L4', content: '五条铁律' }, + { id: 'L5', content: 'MCP 工具 index' }, + { id: 'L6', content: '能力唤醒' }, + { id: 'L7', content: '协作哲学' }, +]; + +test('getL0ManifestViaSubprocess passes --manifest-out and returns parsed L1-L7', async () => { + clearL0Cache(); + const root = makeRoot(); + const spawnFn = buildManifestSpawn({ compiled: 'PROMPT', manifest: L_MANIFEST }); + const manifest = await getL0ManifestViaSubprocess({ catId: 'opus-47', cwd: root, spawnFn }); + + assert.deepEqual( + manifest.map((s) => s.segmentId), + ['L1', 'L2', 'L3', 'L4', 'L5', 'L6', 'L7'], + ); + assert.equal(manifest[3].content, '五条铁律'); + const call = spawnFn.calls[0]; + assert.ok(call.args.includes('--manifest-out'), 'compiler invoked with --manifest-out'); +}); + +test('manifest rides l0Cache lockstep — string compile is a cache hit afterward', async () => { + clearL0Cache(); + const root = makeRoot(); + const spawnFn = buildManifestSpawn({ compiled: 'PROMPT-BODY', manifest: L_MANIFEST }); + await getL0ManifestViaSubprocess({ catId: 'opus-47', cwd: root, spawnFn }); + // String compile for the same cat must NOT re-spawn (both caches set together). + const str = await compileL0ViaSubprocess({ catId: 'opus-47', cwd: root, spawnFn }); + assert.equal(str, 'PROMPT-BODY'); + assert.equal(spawnFn.calls.length, 1, 'only one subprocess for both string + manifest'); +}); + +test('second manifest read is cache-first (no re-spawn)', async () => { + clearL0Cache(); + const root = makeRoot(); + const spawnFn = buildManifestSpawn({ manifest: L_MANIFEST }); + await getL0ManifestViaSubprocess({ catId: 'codex', cwd: root, spawnFn }); + await getL0ManifestViaSubprocess({ catId: 'codex', cwd: root, spawnFn }); + assert.equal(spawnFn.calls.length, 1, 'manifest cache-first — no second spawn'); +}); + +test('clearL0Cache drops the manifest (next read re-spawns)', async () => { + clearL0Cache(); + const root = makeRoot(); + const spawnFn = buildManifestSpawn({ manifest: L_MANIFEST }); + await getL0ManifestViaSubprocess({ catId: 'opus-47', cwd: root, spawnFn }); + clearL0Cache('opus-47'); + await getL0ManifestViaSubprocess({ catId: 'opus-47', cwd: root, spawnFn }); + assert.equal(spawnFn.calls.length, 2, 'manifest cleared with string cache — re-spawned'); +}); + +test('fail-open: missing/garbage manifest → [] but string compile still succeeds', async () => { + clearL0Cache(); + const root = makeRoot(); + // manifest:null → fake does not write the manifest file at all + const spawnFn = buildManifestSpawn({ compiled: 'STILL-COMPILES', manifest: null }); + const str = await compileL0ViaSubprocess({ catId: 'opus-47', cwd: root, spawnFn }); + assert.equal(str, 'STILL-COMPILES', 'critical string compile unaffected by missing manifest'); + const manifest = await getL0ManifestViaSubprocess({ catId: 'opus-47', cwd: root, spawnFn }); + assert.deepEqual(manifest, [], 'no manifest → empty (visible signal), not a throw'); +}); diff --git a/packages/api/test/f257-lseries-trace.test.js b/packages/api/test/f257-lseries-trace.test.js new file mode 100644 index 0000000000..51efdec594 --- /dev/null +++ b/packages/api/test/f257-lseries-trace.test.js @@ -0,0 +1,342 @@ +/** + * F257 #2 — native-L0 L-series (L1-L7) observability via the ACTUAL L0 compiler manifest. + * + * Reworked per sol 2b R1: the trace is sourced from the compiled artifact + * (`getL0ManifestViaSubprocess`), not an out-of-band pipeline reconstruction. Covers: + * - adapter: manifest → session PipelineResult (fired L1-L7; empty → null); + * - bridge: ObservedSegments + delivery channel `native-l0` (P1-1); + * - §16e reachability: persisted L4 found by the segment-lifeline predicate; + * - producer seam (persistNativeL0SessionTrace): success persists L1-L7 with native-l0 + * channel; empty manifest → visible warning + NO false L data (P2-1 failure path). + */ + +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, before, describe, test } from 'node:test'; + +// ── FakeRedis (ZSET + SADD/SMEMBERS) — mirrors segment-lifeline.test.js ── +class FakeRedis { + constructor() { + this.kv = new Map(); + this.sorted = new Map(); + this.sets = new Map(); + } + async set(key, value) { + this.kv.set(key, value); + return 'OK'; + } + async get(key) { + return this.kv.get(key) ?? null; + } + async del(key) { + this.kv.delete(key); + this.sets.delete(key); + this.sorted.delete(key); + return 1; + } + async zadd(key, score, member) { + const s = this.sorted.get(key) ?? new Map(); + s.set(member, score); + this.sorted.set(key, s); + return 1; + } + async zrangebyscore(key, min, max) { + const s = this.sorted.get(key); + if (!s) return []; + return [...s.entries()] + .filter(([, sc]) => sc >= min && sc <= max) + .sort((a, b) => a[1] - b[1]) + .map(([m]) => m); + } + async zrevrange(key, start, stop) { + const s = this.sorted.get(key); + if (!s) return []; + return [...s.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(start, stop + 1) + .map(([m]) => m); + } + async zrem(key, member) { + return this.sorted.get(key)?.delete(member) ? 1 : 0; + } + async sadd(key, ...members) { + const s = this.sets.get(key) ?? new Set(); + for (const m of members) s.add(m); + this.sets.set(key, s); + return members.length; + } + async smembers(key) { + return [...(this.sets.get(key) ?? [])]; + } + async scan(_c, ...args) { + const i = args.indexOf('MATCH'); + const pat = i >= 0 ? args[i + 1] : '*'; + const rx = new RegExp(`^${pat.replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&').replace(/\*/g, '.*')}$`); + return ['0', [...new Set([...this.kv.keys(), ...this.sorted.keys()])].filter((k) => rx.test(k))]; + } + + multi() { + return new FakeMulti(this); + } +} + +class FakeMulti { + constructor(redis) { + this.redis = redis; + this.ops = []; + this.failAt = null; + } + set(key, value) { + this.ops.push({ cmd: 'set', key, value }); + return this; + } + sadd(key, ...members) { + this.ops.push({ cmd: 'sadd', key, members }); + return this; + } + del(key) { + this.ops.push({ cmd: 'del', key }); + return this; + } + /** Test helper: reject the transaction at the Nth operation (1-based). */ + __injectFailureAt(n) { + this.failAt = n; + return this; + } + async exec() { + // Simulate Redis MULTI/EXEC all-or-nothing semantics. + if (this.failAt !== null && this.failAt >= 1 && this.failAt <= this.ops.length) { + throw new Error('injected-transaction-failure'); + } + const results = []; + for (const op of this.ops) { + if (op.cmd === 'set') results.push(await this.redis.set(op.key, op.value)); + else if (op.cmd === 'sadd') results.push(await this.redis.sadd(op.key, ...op.members)); + else if (op.cmd === 'del') results.push(await this.redis.del(op.key)); + } + return results; + } +} + +function makeRoot() { + const root = mkdtempSync(join(tmpdir(), 'l0-lseries-')); + mkdirSync(join(root, 'scripts'), { recursive: true }); + writeFileSync(join(root, 'scripts', 'compile-system-prompt-l0.mjs'), '// fake'); + return root; +} + +/** Fake spawn that writes the compiler manifest to --manifest-out (like the real CLI). */ +function buildManifestSpawn({ compiled = 'PROMPT', manifest = [] }) { + const fn = function fakeSpawn(_cmd, args) { + fn.calls.push(args); + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + setImmediate(() => { + const oi = args.indexOf('--out'); + if (oi >= 0 && args[oi + 1]) writeFileSync(args[oi + 1], compiled, 'utf8'); + const mi = args.indexOf('--manifest-out'); + if (mi >= 0 && args[mi + 1]) writeFileSync(args[mi + 1], JSON.stringify(manifest), 'utf8'); + if (oi < 0) child.stdout.emit('data', Buffer.from(compiled)); + child.emit('close', 0); + }); + return child; + }; + fn.calls = []; + return fn; +} + +const RAW = [ + { id: 'L1', content: '你不是一个孤立的工具' }, + { id: 'L2', content: '客观性 carry-over' }, + { id: 'L3', content: '传球三选一' }, + { id: 'L4', content: '五条铁律:Runtime data safety…' }, + { id: 'L5', content: 'MCP 工具 index' }, + { id: 'L6', content: '能力唤醒' }, + { id: 'L7', content: '协作哲学' }, +]; +const L_IDS = ['L1', 'L2', 'L3', 'L4', 'L5', 'L6', 'L7']; +const manifestContent = RAW.map((e) => ({ segmentId: e.id, content: e.content })); + +describe('F257 #2: native-L0 L-series via compiler manifest', () => { + let adapter; + let bridge; + let StoreMod; + let l0c; + let native; + + before(async () => { + adapter = await import('../dist/domains/prompt-hooks/l0-manifest-trace.js'); + bridge = await import('../dist/domains/prompt-hooks/trace-bridge.js'); + StoreMod = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + l0c = await import('../dist/domains/cats/services/agents/providers/l0-compiler.js'); + native = await import('../dist/domains/prompt-hooks/native-l0-trace.js'); + }); + + after(() => l0c?.clearL0Cache()); + + test('l0ManifestToSessionResult → L1-L7 fired events + content patches', () => { + const r = adapter.l0ManifestToSessionResult(manifestContent); + assert.ok(r, 'non-null for a populated manifest'); + assert.deepEqual(r.events.map((e) => e.hookId).sort(), L_IDS); + assert.ok( + r.events.every((e) => e.status === 'fired'), + 'all fired', + ); + assert.ok( + r.events.every((e) => e.contentHash && typeof e.version === 'number'), + 'hash+version set', + ); + assert.ok( + r.patches.every((p) => p.content.length > 0), + 'patches carry the compiled content', + ); + }); + + test('empty manifest → null (visible signal, not a silent empty session)', () => { + assert.equal(adapter.l0ManifestToSessionResult([]), null); + assert.match(adapter.validateL0Manifest([]), /expected exactly 7/); + }); + + // 2b R2 P1-1: the manifest is ONE atomic L1-L7 artifact. A partial / foreign / duplicate / + // reordered / blank-content manifest is a producer regression → reject the WHOLE thing, + // never persist a partial "healthy" trace. red→green: every violation → reason + null. + describe('atomic manifest validation (P1-1)', () => { + const drop = (id) => manifestContent.filter((s) => s.segmentId !== id); + const cases = [ + ['partial (missing L4)', drop('L4'), /got 6/], + ['extra/foreign row (L1-L7 + X)', [...manifestContent, { segmentId: 'X9', content: 'foreign' }], /got 8/], + [ + 'foreign id replacing L4', + manifestContent.map((s) => (s.segmentId === 'L4' ? { segmentId: 'Z4', content: 'x' } : s)), + /must be L4/, + ], + [ + 'duplicate (L1 twice, missing L7)', + [manifestContent[0], ...manifestContent.slice(0, 6)], + /must be L2, got "L1"/, + ], + [ + 'blank content (L4 empty)', + manifestContent.map((s) => (s.segmentId === 'L4' ? { segmentId: 'L4', content: ' ' } : s)), + /L4 has blank content/, + ], + [ + 'reordered (L2 before L1)', + [manifestContent[1], manifestContent[0], ...manifestContent.slice(2)], + /must be L1, got "L2"/, + ], + ]; + for (const [name, mf, reasonRe] of cases) { + test(`rejects ${name} → null + descriptive reason`, () => { + assert.match(adapter.validateL0Manifest(mf), reasonRe, `${name} reason`); + assert.equal(adapter.l0ManifestToSessionResult(mf), null, `${name} → null (no partial persist)`); + }); + } + + test('exactly canonical L1-L7 non-blank → valid (null reason)', () => { + assert.equal(adapter.validateL0Manifest(manifestContent), null); + assert.ok(adapter.l0ManifestToSessionResult(manifestContent)); + }); + }); + + test('bridge maps to observed L1-L7 with native-l0 delivery channel (P1-1)', () => { + const sessionResult = adapter.l0ManifestToSessionResult(manifestContent); + const b = bridge.buildFromPipeline(sessionResult, null, { + turnId: 't1', + threadId: 'thread-A', + catId: 'opus', + hasNativeL0: true, + sessionFromNativeCompiler: true, + }); + const lSegs = b.summary.segments.filter((s) => /^L\d/.test(s.segmentId)); + assert.equal(lSegs.length, 7); + assert.ok(lSegs.every((s) => s.status === 'observed' && s.pipelineStatus === 'fired')); + const session = b.summary.delivery.find((d) => d.stage === 'session-init'); + assert.equal(session.channel, 'native-l0', 'L1-L7 delivered via native L0, not pack-only'); + }); + + test('§16e reachability: persisted L4 found by segment-lifeline predicate', async () => { + const sessionResult = adapter.l0ManifestToSessionResult(manifestContent); + const b = bridge.buildFromPipeline(sessionResult, null, { + turnId: 't1', + threadId: 'thread-A', + catId: 'opus', + hasNativeL0: true, + sessionFromNativeCompiler: true, + }); + const store = new StoreMod.InjectionTraceStore(new FakeRedis()); + await store.persist(b.summary, b.detail); + const threadIds = await store.listTracedThreadIds(); + assert.ok(threadIds.includes('thread-A')); + const summaries = await store.queryWindow('thread-A', 0, Date.now() + 1000); + const found = summaries.flatMap((s) => s.segments).filter((s) => s.segmentId === 'L4' && s.status === 'observed'); + assert.equal(found.length, 1, 'L4 reachable by the exact lifeline predicate'); + assert.ok(found[0].charCount > 0); + }); + + test('seam: persistNativeL0SessionTrace persists L1-L7 (native-l0) from the compiler cache', async () => { + l0c.clearL0Cache(); + const root = makeRoot(); + const spawnFn = buildManifestSpawn({ manifest: RAW }); + // Warm the manifest cache via the fake compiler; the helper's cache-first read hits it. + await l0c.getL0ManifestViaSubprocess({ catId: 'opus-47', cwd: root, spawnFn }); + + const persisted = []; + const replaySnapshots = []; + const warns = []; + await native.persistNativeL0SessionTrace({ + traceStore: { + persist: async (summary, detail) => persisted.push({ summary, detail }), + persistReplaySnapshots: async (_threadId, _turnId, snapshots) => replaySnapshots.push(snapshots), + }, + catId: 'opus-47', + threadId: 'thread-A', + turnId: 't1', + turnResult: null, + log: { warn: (_o, m) => warns.push(m) }, + }); + + assert.equal(persisted.length, 1, 'trace persisted'); + const lSegs = persisted[0].summary.segments.filter((s) => /^L\d/.test(s.segmentId)); + assert.equal(lSegs.length, 7, 'all L1-L7 persisted'); + const session = persisted[0].summary.delivery.find((d) => d.stage === 'session-init'); + assert.equal(session.channel, 'native-l0'); + assert.equal(warns.length, 0, 'no producer warning when manifest present'); + }); + + // 2b R2 P1-1/P2-1: a regressed producer (empty OR partial manifest) must hit the visible + // producer-failure path — warning fired, ZERO fabricated L segments persisted. + for (const [label, catId, manifest] of [ + ['empty manifest', 'codex', []], + ['partial manifest (only L1 — L2-L7 dropped)', 'sol', [{ id: 'L1', content: 'only-one' }]], + ]) { + test(`seam failure path: ${label} → visible warning + NO false L data`, async () => { + l0c.clearL0Cache(); + const root = makeRoot(); + const spawnFn = buildManifestSpawn({ compiled: 'STILL-COMPILES', manifest }); + await l0c.getL0ManifestViaSubprocess({ catId, cwd: root, spawnFn }); + + const persisted = []; + const warns = []; + await native.persistNativeL0SessionTrace({ + traceStore: { persist: async (summary) => persisted.push(summary) }, + catId, + threadId: 'thread-B', + turnId: 't2', + turnResult: null, + log: { warn: (_o, m) => warns.push(m) }, + }); + + assert.ok( + warns.some((m) => /manifest rejected/.test(m)), + 'regressed manifest emits a visible producer warning (distinguishable from healthy zero)', + ); + const lPersisted = persisted.flatMap((s) => s.segments ?? []).filter((s) => /^L\d/.test(s.segmentId)); + assert.equal(lPersisted.length, 0, 'no fabricated L segments — partial success is never persisted'); + }); + } +}); diff --git a/packages/api/test/f257-objective-registry.test.js b/packages/api/test/f257-objective-registry.test.js new file mode 100644 index 0000000000..a3ebed6175 --- /dev/null +++ b/packages/api/test/f257-objective-registry.test.js @@ -0,0 +1,122 @@ +/** + * F257 修复清单 #3 — objective registry loader. + * + * 契约(2a R1 修订):parseObjectiveRegistry 返回 discriminated Result。 + * 合法 → {ok:true, registry:{registryVersion, objectives:[{id,statement}]}}。 + * malformed YAML / 非 mapping / 非正整数 version / objectives 非数组 / 任一非法行 + * (缺/空白 id·statement、id 不匹配 pattern、重复 id)→ {ok:false, error}(fail-closed, + * 绝不静默塌成空 catalog)。并验 shipped registry.yaml 含 canonized 目标且**无 segments**。 + */ + +import assert from 'node:assert/strict'; +import { dirname, resolve } from 'node:path'; +import { describe, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const { parseObjectiveRegistry, loadObjectiveRegistry } = await import( + '../dist/infrastructure/harness-eval/objective-registry.js' +); + +const testDir = dirname(fileURLToPath(import.meta.url)); +const shippedRegistryPath = resolve( + testDir, + '..', + '..', + '..', + 'docs', + 'harness-feedback', + 'objectives', + 'registry.yaml', +); + +describe('F257 #3 — parseObjectiveRegistry (valid)', () => { + test('parses valid registry (id/statement only — no segments authority)', () => { + const r = parseObjectiveRegistry('registryVersion: 1\nobjectives:\n - id: obj-x\n statement: does x\n'); + assert.equal(r.ok, true); + assert.equal(r.registry.registryVersion, 1); + assert.deepEqual(r.registry.objectives, [{ id: 'obj-x', statement: 'does x' }]); + // segments must NOT be carried through even if authored (single authority) + assert.equal('segments' in r.registry.objectives[0], false); + }); + + test('trims statement whitespace', () => { + const r = parseObjectiveRegistry('registryVersion: 1\nobjectives:\n - id: obj-y\n statement: " padded "\n'); + assert.equal(r.ok, true); + assert.equal(r.registry.objectives[0].statement, 'padded'); + assert.equal(r.registry.registryVersion, 1); + }); +}); + +describe('F257 #3 — parseObjectiveRegistry (fail-closed, no silent empty)', () => { + const cases = [ + ['malformed YAML', ': : [unclosed'], + ['non-mapping root', '- just\n- a\n- list\n'], + ['version -2.5 (sol repro)', 'registryVersion: -2.5\nobjectives: []\n'], + ['version 0', 'registryVersion: 0\nobjectives: []\n'], + ['version non-integer 1.5', 'registryVersion: 1.5\nobjectives: []\n'], + ['missing registryVersion', 'objectives: []\n'], + // 2a R3 P2-1: unsupported versions must fail closed (loader implements only v1). + ['unsupported version 2', 'registryVersion: 2\nobjectives: []\n'], + ['unsupported version 999', 'registryVersion: 999\nobjectives: []\n'], + ['objectives not an array', 'registryVersion: 1\nobjectives: nope\n'], + ['missing id', 'registryVersion: 1\nobjectives:\n - statement: no id\n'], + ['whitespace-only id (sol repro)', 'registryVersion: 1\nobjectives:\n - id: " "\n statement: x\n'], + ['whitespace-only statement (sol repro)', 'registryVersion: 1\nobjectives:\n - id: obj-x\n statement: " "\n'], + ['id not matching pattern', 'registryVersion: 1\nobjectives:\n - id: Routing_Delivery\n statement: x\n'], + [ + 'duplicate ids (sol repro)', + 'registryVersion: 1\nobjectives:\n - id: obj-x\n statement: a\n - id: obj-x\n statement: b\n', + ], + // 2a R2 P2-1: forbidden/unknown fields must REJECT (not silently strip), so a stray + // `segments` can't reappear and be mistaken for挂靠 authority. + [ + 'segments field (forbidden, not stripped)', + 'registryVersion: 1\nobjectives:\n - id: obj-x\n statement: x\n segments: [S1, D1]\n', + ], + ['unknown entry key', 'registryVersion: 1\nobjectives:\n - id: obj-x\n statement: x\n weight: 3\n'], + ['unknown root key', 'registryVersion: 1\nfoo: bar\nobjectives: []\n'], + ]; + for (const [name, yaml] of cases) { + test(`rejects: ${name}`, () => { + const r = parseObjectiveRegistry(yaml); + assert.equal(r.ok, false, `${name} must fail-closed`); + assert.equal(typeof r.error, 'string'); + assert.ok(r.error.length > 0, 'error reason is non-empty'); + }); + } + + test('segments rejection is descriptive (points to UnitEvaluationManifest authority)', () => { + const r = parseObjectiveRegistry( + 'registryVersion: 1\nobjectives:\n - id: obj-x\n statement: x\n segments: [S1]\n', + ); + assert.equal(r.ok, false); + assert.match(r.error, /segments/); + assert.match(r.error, /UnitEvaluationManifest/); + }); + + test('valid-but-empty objectives is honestly ok (not a failure)', () => { + const r = parseObjectiveRegistry('registryVersion: 1\nobjectives: []\n'); + assert.equal(r.ok, true); + assert.deepEqual(r.registry.objectives, []); + }); +}); + +describe('F257 #3 — loadObjectiveRegistry', () => { + test('nonexistent path → ok:false (fail-closed, distinguishable from empty)', async () => { + const r = await loadObjectiveRegistry('/no/such/registry.yaml'); + assert.equal(r.ok, false); + assert.match(r.error, /unreadable/); + }); + + test('shipped registry.yaml → ok, canonized objectives, no segments', async () => { + const r = await loadObjectiveRegistry(shippedRegistryPath); + assert.equal(r.ok, true, r.ok ? '' : r.error); + const ids = r.registry.objectives.map((o) => o.id); + assert.ok(ids.includes('obj-routing-delivery'), 'obj-routing-delivery registered'); + assert.ok(ids.includes('obj-identity-integrity'), 'obj-identity-integrity registered'); + for (const o of r.registry.objectives) { + assert.ok(o.id.length > 0 && o.statement.length > 0, `objective ${o.id} has id + statement`); + assert.equal('segments' in o, false, `objective ${o.id} carries no segments authority`); + } + }); +}); diff --git a/packages/api/test/f257-replay-snapshot-concurrent.test.js b/packages/api/test/f257-replay-snapshot-concurrent.test.js new file mode 100644 index 0000000000..39ab4af0b6 --- /dev/null +++ b/packages/api/test/f257-replay-snapshot-concurrent.test.js @@ -0,0 +1,197 @@ +/** + * F257 Console 判据④ R4 — Redis-only concurrent fault drills for replay snapshots. + * + * Verifies that durable replay snapshots are written and deleted as a single + * atomic lifecycle: a late writer cannot resurrect data after deleteTurn wins, + * and deleteTurn cannot leave orphan snapshots if the writer won first. + */ + +import assert from 'node:assert/strict'; +import { after, before, beforeEach, describe, it } from 'node:test'; +import { + assertRedisIsolationOrThrow, + cleanupClientKeyspace, + redisIsolationSkipReason, +} from './helpers/redis-test-helpers.js'; + +const REDIS_URL = process.env.REDIS_URL; + +describe('F257 replay snapshot atomic lifecycle - Redis', { skip: redisIsolationSkipReason(REDIS_URL) }, () => { + let createRedisClient; + let redis; + let InjectionTraceStore; + let connected = false; + + before(async () => { + assertRedisIsolationOrThrow(REDIS_URL, 'f257-replay-snapshot-concurrent'); + + const shared = await import('@cat-cafe/shared/utils'); + createRedisClient = shared.createRedisClient; + + const storeMod = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + InjectionTraceStore = storeMod.InjectionTraceStore; + + redis = createRedisClient({ url: REDIS_URL, keyPrefix: 'f257-replay-race:' }); + try { + await redis.ping(); + connected = true; + } catch { + console.warn('[f257-replay-snapshot-concurrent] Redis unreachable, skipping Redis drills'); + await redis.quit().catch(() => {}); + } + }); + + after(async () => { + if (redis && connected) { + await cleanupClientKeyspace(redis); + await redis.quit(); + } + }); + + beforeEach(async (t) => { + if (!connected) return t.skip('Redis not connected'); + await cleanupClientKeyspace(redis); + }); + + function makeTurn(threadId, turnId) { + const summary = { + turnId, + threadId, + catId: 'opus', + timestamp: Date.now(), + segments: [], + delivery: [], + totalCharCount: 0, + totalTokenEstimate: 0, + totalSegmentsObserved: 0, + totalSegmentsAbsent: 0, + durationMs: 0, + }; + const detail = { + turnId, + threadId, + catId: 'opus', + timestamp: summary.timestamp, + sessionContentHash: null, + turnContentHash: null, + sessionCharCount: 0, + sessionTokenEstimate: 0, + turnCharCount: 0, + turnTokenEstimate: 0, + segments: [], + }; + return { summary, detail }; + } + + function makeSnapshot(threadId, turnId, segmentId) { + return { + segmentId, + threadId, + turnId, + timestamp: Date.now(), + catId: 'opus', + stage: 'session-init', + pipelineStatus: 'fired', + version: 1, + content: 'rendered content', + contentSourceKind: 'template', + contentSourceRef: 'templates/S-test.md', + templateVars: { VAR: 'value' }, + messageAnchorId: null, + surroundingMessageIds: [], + surroundingMessagesGap: null, + ownerUserId: 'test-user', + }; + } + + it('deleteTurn vs late persistReplaySnapshots race leaves no resurrected snapshot', async () => { + const store = new InjectionTraceStore(redis); + const threadId = 'race-thread'; + const turnId = 'race-turn'; + + const { summary, detail } = makeTurn(threadId, turnId); + await store.persist(summary, detail); + + // Simulate a fire-and-forget writer that has already crossed the event loop + // by the time deleteTurn is issued. + await store.deleteTurn(threadId, turnId); + await store.persistReplaySnapshots(threadId, turnId, [makeSnapshot(threadId, turnId, 'S-late')]); + + const got = await store.getReplaySnapshot(threadId, turnId, 'S-late'); + assert.equal(got, null, 'late writer after delete must be suppressed by CAS'); + }); + + it('concurrent deleteTurn and persistReplaySnapshots end with no snapshot', async () => { + const store = new InjectionTraceStore(redis); + const threadId = 'race-thread'; + const turnId = 'race-turn'; + + const { summary, detail } = makeTurn(threadId, turnId); + await store.persist(summary, detail); + + // Fire both operations at Redis without awaiting ordering. + await Promise.all([ + store.deleteTurn(threadId, turnId), + store.persistReplaySnapshots(threadId, turnId, [makeSnapshot(threadId, turnId, 'S-concurrent')]), + ]); + + const got = await store.getReplaySnapshot(threadId, turnId, 'S-concurrent'); + assert.equal(got, null, 'delete must win the race without orphan snapshots'); + }); + + it('repeated delete-then-write cycles do not leak snapshot keys', async () => { + const store = new InjectionTraceStore(redis); + const threadId = 'cycle-thread'; + const turnId = 'cycle-turn'; + + for (let i = 0; i < 10; i++) { + const { summary, detail } = makeTurn(threadId, `${turnId}-${i}`); + await store.persist(summary, detail); + await store.persistReplaySnapshots(summary.threadId, summary.turnId, [ + makeSnapshot(summary.threadId, summary.turnId, 'S1'), + ]); + await store.deleteTurn(summary.threadId, summary.turnId); + } + + const keys = await redis.keys(`${redis.options?.keyPrefix ?? ''}replay-snapshot:cycle-thread:*`); + assert.equal(keys.length, 0, 'no durable replay snapshot keys leaked'); + }); + + it('deleteTurn isolates sibling turns in shared thread index', async () => { + const store = new InjectionTraceStore(redis); + const threadId = 'sibling-thread'; + + const a = makeTurn(threadId, 'turn-a'); + a.summary.timestamp = 1000; + a.detail.timestamp = 1000; + const b = makeTurn(threadId, 'turn-b'); + b.summary.timestamp = 2000; + b.detail.timestamp = 2000; + + await store.persist(a.summary, a.detail); + await store.persist(b.summary, b.detail); + await store.persistReplaySnapshots(threadId, 'turn-a', [makeSnapshot(threadId, 'turn-a', 'S-a')]); + await store.persistReplaySnapshots(threadId, 'turn-b', [makeSnapshot(threadId, 'turn-b', 'S-b')]); + + await store.deleteTurn(threadId, 'turn-a'); + + const { turnIds, total } = await store.listTurnIds(threadId); + assert.equal(total, 1); + assert.deepEqual(turnIds, ['turn-b']); + + const window = await store.queryWindow(threadId, 1500, 2500); + assert.equal(window.length, 1); + assert.equal(window[0].turnId, 'turn-b'); + + assert.equal(await store.getSummary(threadId, 'turn-a'), null); + assert.equal(await store.getReplaySnapshot(threadId, 'turn-a', 'S-a'), null); + + const bSummary = await store.getSummary(threadId, 'turn-b'); + assert.ok(bSummary); + assert.equal(bSummary.turnId, 'turn-b'); + + const bSnapshot = await store.getReplaySnapshot(threadId, 'turn-b', 'S-b'); + assert.ok(bSnapshot); + assert.equal(bSnapshot.turnId, 'turn-b'); + }); +}); diff --git a/packages/api/test/f257-route-seam.test.js b/packages/api/test/f257-route-seam.test.js new file mode 100644 index 0000000000..82d083257c --- /dev/null +++ b/packages/api/test/f257-route-seam.test.js @@ -0,0 +1,263 @@ +/** + * F257 #2 (2b R2 P2-2) — route seam: native-L0 identity is persisted via the compiler + * manifest through BOTH route-serial and route-parallel, and non-native routing stays on + * its existing session-trace path. + * + * The unit seam test starts at persistNativeL0SessionTrace and can't catch a route dropping + * or mis-branching the call. This drives the real routes with a bootstrapped fake trace + * store + a prewarmed manifest cache (sol's recipe — no broad full-suite driver): a + * native-L0 service must yield persisted L1-L7 with channel `native-l0`; a non-native + * service must NOT (no compiler L segments, not native-l0). + */ + +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, before, describe, test } from 'node:test'; + +class FakeRedis { + constructor() { + this.kv = new Map(); + this.sorted = new Map(); + this.sets = new Map(); + } + async set(k, v) { + this.kv.set(k, v); + return 'OK'; + } + async get(k) { + return this.kv.get(k) ?? null; + } + async del(k) { + this.kv.delete(k); + return 1; + } + async zadd(k, score, m) { + const s = this.sorted.get(k) ?? new Map(); + s.set(m, score); + this.sorted.set(k, s); + return 1; + } + async zrangebyscore(k, min, max) { + const s = this.sorted.get(k); + if (!s) return []; + return [...s.entries()] + .filter(([, sc]) => sc >= min && sc <= max) + .sort((a, b) => a[1] - b[1]) + .map(([m]) => m); + } + async zrevrange(k, a, b) { + const s = this.sorted.get(k); + if (!s) return []; + return [...s.entries()] + .sort((x, y) => y[1] - x[1]) + .slice(a, b + 1) + .map(([m]) => m); + } + async zrem(k, m) { + return this.sorted.get(k)?.delete(m) ? 1 : 0; + } + async sadd(k, ...ms) { + const s = this.sets.get(k) ?? new Set(); + for (const m of ms) s.add(m); + this.sets.set(k, s); + return ms.length; + } + async smembers(k) { + return [...(this.sets.get(k) ?? [])]; + } + async scan(_c, ...args) { + const i = args.indexOf('MATCH'); + const pat = i >= 0 ? args[i + 1] : '*'; + const rx = new RegExp(`^${pat.replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&').replace(/\*/g, '.*')}$`); + return ['0', [...new Set([...this.kv.keys(), ...this.sorted.keys()])].filter((x) => rx.test(x))]; + } +} + +const RAW = ['L1', 'L2', 'L3', 'L4', 'L5', 'L6', 'L7'].map((id) => ({ id, content: `${id} governance content` })); + +function makeRoot() { + const root = mkdtempSync(join(tmpdir(), 'l0-seam-')); + mkdirSync(join(root, 'scripts'), { recursive: true }); + writeFileSync(join(root, 'scripts', 'compile-system-prompt-l0.mjs'), '// fake'); + return root; +} + +function buildManifestSpawn(manifest) { + return function fakeSpawn(_cmd, args) { + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + setImmediate(() => { + const mi = args.indexOf('--manifest-out'); + if (mi >= 0 && args[mi + 1]) writeFileSync(args[mi + 1], JSON.stringify(manifest), 'utf8'); + child.stdout.emit('data', Buffer.from('PROMPT')); + child.emit('close', 0); + }); + return child; + }; +} + +function mockService(catId, { native }) { + return { + async *invoke() { + yield { type: 'text', catId, content: 'reply', timestamp: Date.now() }; + yield { type: 'done', catId, timestamp: Date.now() }; + }, + ...(native ? { injectsL0Natively: () => true } : {}), + }; +} + +function createMockDeps(services) { + let inv = 0; + let msg = 0; + const byId = new Map(); + return { + services, + injectionTraceStore: true, // truthy → route runs the trailing drainCapturedTraces() + invocationDeps: { + registry: { + create: () => ({ invocationId: `inv-${++inv}`, callbackToken: `tok-${inv}` }), + verify: () => ({ ok: false, reason: 'unknown_invocation' }), + }, + sessionManager: { get: async () => null, getOrCreate: async () => ({}), resolveWorkingDirectory: () => '/tmp/t' }, + threadStore: { + get: async () => null, + getParticipantsWithActivity: async () => [], + updateParticipantActivity: async () => {}, + consumeMentionRoutingFeedback: async () => null, + isRebornSession: async () => false, + }, + apiUrl: 'http://127.0.0.1:3004', + }, + messageStore: { + append: async (m) => { + const s = { id: `m-${++msg}`, ...m, threadId: m.threadId ?? 'default' }; + byId.set(s.id, s); + return s; + }, + getById: async (id) => byId.get(id) ?? null, + getRecent: () => [], + getMentionsFor: () => [], + getRecentMentionsFor: () => [], + getBefore: () => [], + getByThread: () => [], + getByThreadAfter: () => [], + getByThreadBefore: () => [], + }, + draftStore: { delete: () => Promise.resolve(), touch: () => Promise.resolve(), upsert: () => Promise.resolve() }, + socketManager: { broadcastToRoom: () => {} }, + }; +} + +async function pollTrace(store, threadId, predicate, timeoutMs = 1500) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const summaries = await store.queryWindow(threadId, 0, Date.now() + 1000); + const hit = summaries.find(predicate); + if (hit) return hit; + await new Promise((r) => setTimeout(r, 20)); + } + return null; +} + +describe('F257 #2 route seam (2b R2 P2-2)', () => { + let routeParallel; + let routeSerial; + let l0c; + let StoreMod; + let catReg; + let store; + + before(async () => { + const shared = await import('@cat-cafe/shared'); + catReg = shared.catRegistry; + catReg.reset(); + for (const id of ['nativecat', 'plaincat']) { + catReg.register(id, { + displayName: '布偶猫', + nickname: id, + name: 'Ragdoll', + roleDescription: 'x', + personality: 'y', + defaultModel: 'claude-opus-4-6', + mentionPatterns: [`@${id}`], + restrictions: [], + clientId: 'anthropic', + breedId: 'ragdoll', + }); + } + routeParallel = (await import('../dist/domains/cats/services/agents/routing/route-parallel.js')).routeParallel; + routeSerial = (await import('../dist/domains/cats/services/agents/routing/route-serial.js')).routeSerial; + l0c = await import('../dist/domains/cats/services/agents/providers/l0-compiler.js'); + StoreMod = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + const traceBootstrap = await import('../dist/domains/prompt-hooks/trace-bootstrap.js'); + + const redis = new FakeRedis(); + traceBootstrap.bootstrapTraceStore(redis); + store = new StoreMod.InjectionTraceStore(redis); + + // Prewarm the manifest cache so the route's cache-first read hits it (no real subprocess). + l0c.clearL0Cache(); + await l0c.getL0ManifestViaSubprocess({ catId: 'nativecat', cwd: makeRoot(), spawnFn: buildManifestSpawn(RAW) }); + }); + + after(() => { + catReg?.reset(); + l0c?.clearL0Cache(); + }); + + async function drain(route, catId, threadId) { + for await (const _m of route( + createMockDeps({ [catId]: mockService(catId, { native: catId === 'nativecat' }) }), + [catId], + 'hi', + 'user1', + threadId, + {}, + )) { + // drain the route generator + } + } + + for (const [mode, getRoute] of [ + ['parallel', () => routeParallel], + ['serial', () => routeSerial], + ]) { + test(`${mode}: native-L0 cat persists L1-L7 via the compiler manifest (native-l0 channel)`, async () => { + const threadId = `seam-${mode}-native`; + await drain(getRoute(), 'nativecat', threadId); + const summary = await pollTrace(store, threadId, (s) => s.segments.some((x) => x.segmentId === 'L4')); + assert.ok(summary, `${mode}: native-L0 trace was persisted`); + const lSegs = summary.segments.filter((s) => /^L\d/.test(s.segmentId)); + assert.equal(lSegs.length, 7, 'all L1-L7 present'); + assert.ok(lSegs.every((s) => s.status === 'observed' && s.pipelineStatus === 'fired')); + const session = summary.delivery.find((d) => d.stage === 'session-init'); + assert.equal(session.channel, 'native-l0', 'session delivered via native L0'); + }); + + test(`${mode}: non-native cat stays on the existing pipeline path (message-prepend, S/D segments, no compiler L)`, async () => { + const threadId = `seam-${mode}-plain`; + await drain(getRoute(), 'plaincat', threadId); + // Non-vacuous: REQUIRE the existing path to have actually persisted a trace. If the + // non-native persistence were deleted/broken, summaries=[] would make the "no native-l0 + // / no L" checks pass falsely — so first prove a trace exists, then assert its shape. + const summary = await pollTrace(store, threadId, (s) => s.segments.length > 0); + assert.ok(summary, `${mode}: non-native path persisted a trace (existing pipeline ran)`); + const session = summary.delivery.find((d) => d.stage === 'session-init'); + assert.equal(session.channel, 'message-prepend', 'non-native session uses message-prepend, not native-l0'); + assert.ok( + !summary.segments.some((x) => /^L\d/.test(x.segmentId)), + 'non-native session trace carries no compiler L segments', + ); + const pipelineSeg = summary.segments.find((x) => /^[SD]\d/.test(x.segmentId)); + assert.ok(pipelineSeg, 'existing pipeline S/D segments present (path unchanged)'); + assert.ok( + ['observed', 'absent'].includes(pipelineSeg.status), + 'existing pipeline segment carries a real observed/absent status', + ); + }); + } +}); diff --git a/packages/api/test/f257-routing-attempts.test.js b/packages/api/test/f257-routing-attempts.test.js new file mode 100644 index 0000000000..b3d697c638 --- /dev/null +++ b/packages/api/test/f257-routing-attempts.test.js @@ -0,0 +1,788 @@ +/** + * F257 V1 — RoutingAttemptDraft red baseline. + * + * Semantics single source of truth: T-A (§3.4) in + * docs/features/assets/F257/objective-driven-redesign-v1.md (v2.3.2 FINAL). + * Tests assert behavior per T-A rows by outcome name; definitions are NOT + * restated here — when a test contradicts T-A, T-A wins. + * + * Covers (per T-A "V1 实现动作" column, full set): + * - one draft per unique source span (attempt-stream uniqueness contract) + * - tokenOrdinal assigned once after all passes merge, ordered by span start + * - parser 改造①: self_excluded tokenized (a2a) + * - parser 改造②: unknown_token emitted before line break (a2a) + * - (右截断): cap ≠ truncated — read-only scan must confirm extra + * metric-affecting tokens before truncated=true / batch metricEligible=false + * - parser 改造③: duplicate = distinct-span-same-target only; same-span + * re-visit is a traversal artifact (merged silently, outcome unchanged) + * - parser 改造④: group keywords classified before unknown (user mode) + */ + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +const ROUTING_DIR = '../dist/domains/cats/services/agents/routing'; + +async function loadA2A() { + return import(`${ROUTING_DIR}/a2a-mentions.js`); +} + +async function loadAttemptModule() { + return import(`${ROUTING_DIR}/routing-attempt.js`); +} + +function createNoopService(catId) { + return { + invoke: async function* () { + yield { type: 'done', catId, timestamp: Date.now() }; + }, + }; +} + +function createNoopRegistry() { + return { + create: () => ({ invocationId: 'inv-1', callbackToken: 'cb-1' }), + update: () => {}, + get: () => null, + }; +} + +function createNoopMessageStore() { + return { + append: () => ({}), + getRecent: () => [], + getMentionsFor: () => [], + getByThreadBefore: () => [], + getByThreadAfter: () => [], + getById: () => null, + softDelete: () => null, + restore: () => null, + }; +} + +async function createRouter() { + const { AgentRouter } = await import(`${ROUTING_DIR}/AgentRouter.js`); + const { migrateRouterOpts } = await import('./helpers/agent-registry-helpers.js'); + return new AgentRouter( + await migrateRouterOpts({ + claudeService: createNoopService('opus'), + codexService: createNoopService('codex'), + geminiService: createNoopService('gemini'), + registry: createNoopRegistry(), + messageStore: createNoopMessageStore(), + }), + ); +} + +function outcomes(batch) { + return batch.attempts.map((a) => a.outcome); +} + +function assertOrdinalsSortedBySpan(batch) { + const sorted = [...batch.attempts].sort((a, b) => a.span.start - b.span.start || a.span.end - b.span.end); + assert.deepEqual( + batch.attempts.map((a) => a.tokenOrdinal), + batch.attempts.map((_, i) => i), + 'tokenOrdinal must be 0-based consecutive', + ); + assert.deepEqual( + sorted.map((a) => a.tokenOrdinal), + batch.attempts.map((_, i) => i), + 'tokenOrdinal order must equal span-start order (assigned once after merge)', + ); +} + +// --------------------------------------------------------------------------- +// parserMode=a2a (analyzeA2AMentions) +// --------------------------------------------------------------------------- + +describe('F257 T-A parserMode=a2a: attempt batch shape', () => { + it('emits exactly one draft per token with spans and ordinals (resolved x2)', async () => { + const { analyzeA2AMentions } = await loadA2A(); + const r = analyzeA2AMentions('@opus @codex 请看', 'kimi'); + assert.deepEqual(r.mentions, ['opus', 'codex'], 'routing behavior unchanged'); + const batch = r.attemptBatch; + assert.equal(batch.parserMode, 'a2a'); + assert.equal(batch.spanBasis, 'a2a_normalized'); + assert.equal(batch.truncated, false); + assert.equal(batch.metricEligible, true); + assert.equal(batch.attempts.length, 2); + assert.deepEqual(outcomes(batch), ['resolved', 'resolved']); + assert.deepEqual(batch.attempts[0].span, { start: 0, end: 5 }); + assert.deepEqual(batch.attempts[1].span, { start: 6, end: 12 }); + assert.equal(batch.attempts[0].token, '@opus'); + assert.equal(batch.attempts[1].token, '@codex'); + assert.equal(batch.attempts[0].targetCatId, 'opus'); + assert.equal(batch.attempts[1].targetCatId, 'codex'); + assertOrdinalsSortedBySpan(batch); + }); + + it('returns an empty eligible batch for empty text', async () => { + const { analyzeA2AMentions } = await loadA2A(); + const batch = analyzeA2AMentions('', 'opus').attemptBatch; + assert.equal(batch.parserMode, 'a2a'); + assert.deepEqual(batch.attempts, []); + assert.equal(batch.truncated, false); + assert.equal(batch.metricEligible, true); + }); + + it('does not tokenize prose lines (non-line-start mentions produce no attempts)', async () => { + const { analyzeA2AMentions } = await loadA2A(); + const r = analyzeA2AMentions('之前布偶猫说的 @布偶猫 方案不错', 'codex'); + assert.deepEqual(r.mentions, []); + assert.deepEqual(r.attemptBatch.attempts, []); + }); + + it('does not tokenize mentions inside fenced code blocks', async () => { + const { analyzeA2AMentions } = await loadA2A(); + const r = analyzeA2AMentions('```\n@codex review\n```\n@opus 看下', 'kimi'); + assert.deepEqual(r.mentions, ['opus']); + assert.deepEqual(outcomes(r.attemptBatch), ['resolved']); + assert.equal(r.attemptBatch.attempts[0].targetCatId, 'opus'); + }); +}); + +describe('F257 T-A parserMode=a2a: self_excluded (parser 改造①)', () => { + it('tokenizes a self mention as self_excluded and keeps scanning the line', async () => { + const { analyzeA2AMentions } = await loadA2A(); + const r = analyzeA2AMentions('@opus @codex 接力', 'opus'); + // 改造① behavior change: previously the self token aborted the line and + // @codex was silently dropped; per T-A the self token is tokenized and skipped. + assert.deepEqual(r.mentions, ['codex']); + assert.deepEqual(outcomes(r.attemptBatch), ['self_excluded', 'resolved']); + assert.equal(r.attemptBatch.attempts[0].targetCatId, 'opus'); + assert.equal(r.attemptBatch.attempts[1].targetCatId, 'codex'); + assertOrdinalsSortedBySpan(r.attemptBatch); + }); + + it('tokenizes a self alias as self_excluded (no routing)', async () => { + const { analyzeA2AMentions } = await loadA2A(); + // P1-4: @宪宪 removed from opus breed patterns → use @布偶猫 (still a valid opus alias) + const r = analyzeA2AMentions('@布偶猫 我自己说的', 'opus'); + assert.deepEqual(r.mentions, []); + assert.deepEqual(outcomes(r.attemptBatch), ['self_excluded']); + }); + + it('repeated self tokens are each self_excluded (priority row 1 beats duplicate)', async () => { + const { analyzeA2AMentions } = await loadA2A(); + const r = analyzeA2AMentions('@opus @codex\n@布偶猫 hmm', 'opus'); + assert.deepEqual(r.mentions, ['codex']); + assert.deepEqual(outcomes(r.attemptBatch), ['self_excluded', 'resolved', 'self_excluded']); + assertOrdinalsSortedBySpan(r.attemptBatch); + }); +}); + +describe('F257 T-A parserMode=a2a: unknown_token (parser 改造②)', () => { + it('emits unknown_token before abandoning the line, later lines still scanned', async () => { + const { analyzeA2AMentions } = await loadA2A(); + const r = analyzeA2AMentions('@zzzcat 看看\n@codex 你来', 'kimi'); + assert.deepEqual(r.mentions, ['codex'], 'routing behavior unchanged'); + assert.deepEqual(outcomes(r.attemptBatch), ['unknown_token', 'resolved']); + assert.equal(r.attemptBatch.attempts[0].token, '@zzzcat'); + assert.equal(r.attemptBatch.attempts[0].targetCatId, undefined); + assertOrdinalsSortedBySpan(r.attemptBatch); + }); + + it('extracts CJK unknown token up to the next boundary', async () => { + const { analyzeA2AMentions } = await loadA2A(); + const r = analyzeA2AMentions('@不存在的猫 帮我看看', 'kimi'); + assert.deepEqual(r.mentions, []); + assert.deepEqual(outcomes(r.attemptBatch), ['unknown_token']); + assert.equal(r.attemptBatch.attempts[0].token, '@不存在的猫'); + }); + + it('same-line tokens after unknown_token stay unscanned (break preserved)', async () => { + const { analyzeA2AMentions } = await loadA2A(); + const r = analyzeA2AMentions('@zzzcat @codex', 'kimi'); + assert.deepEqual(r.mentions, [], 'routing behavior unchanged'); + assert.deepEqual(outcomes(r.attemptBatch), ['unknown_token']); + }); +}); + +describe('F257 T-A parserMode=a2a: disabled_cat and duplicate', () => { + it('tokenizes a disabled cat as disabled_cat with routing warning preserved', async () => { + const { analyzeA2AMentions } = await loadA2A(); + const r = analyzeA2AMentions('@antigravity 帮看下', 'kimi'); + assert.deepEqual(r.mentions, []); + assert.equal(r.routing_warnings.length, 1); + assert.equal(r.routing_warnings[0].kind, 'cat_disabled'); + assert.deepEqual(outcomes(r.attemptBatch), ['disabled_cat']); + assert.equal(r.attemptBatch.attempts[0].targetCatId, r.routing_warnings[0].catId); + }); + + it('repeated disabled tokens are each disabled_cat (priority row 2 beats duplicate), warning stays deduped', async () => { + const { analyzeA2AMentions } = await loadA2A(); + const r = analyzeA2AMentions('@antigravity @斑斑 都是它', 'kimi'); + assert.equal(r.routing_warnings.length, 1, 'warning dedup unchanged'); + assert.deepEqual(outcomes(r.attemptBatch), ['disabled_cat', 'disabled_cat']); + }); + + it('duplicate = distinct span pointing at an already-resolved target', async () => { + const { analyzeA2AMentions } = await loadA2A(); + const r = analyzeA2AMentions('@opus @布偶猫 同一只', 'kimi'); + assert.deepEqual(r.mentions, ['opus'], 'routing behavior unchanged'); + assert.deepEqual(outcomes(r.attemptBatch), ['resolved', 'duplicate']); + assert.equal(r.attemptBatch.attempts[1].targetCatId, 'opus'); + assert.equal(r.attemptBatch.metricEligible, true, 'duplicate does not invalidate the batch'); + }); +}); + +describe('F257 T-A parserMode=a2a: (右截断) read-only truncation scan', () => { + it('exactly cap resolved targets with no further tokens → NOT truncated', async () => { + const { analyzeA2AMentions } = await loadA2A(); + const r = analyzeA2AMentions('@opus @codex', 'kimi'); + assert.deepEqual(r.mentions, ['opus', 'codex']); + assert.equal(r.attemptBatch.truncated, false); + assert.equal(r.attemptBatch.metricEligible, true); + }); + + it('cap followed by prose (no further tokens) → NOT truncated', async () => { + const { analyzeA2AMentions } = await loadA2A(); + const r = analyzeA2AMentions('@opus @codex 后面是散文\n纯散文行', 'kimi'); + assert.equal(r.attemptBatch.truncated, false); + assert.equal(r.attemptBatch.metricEligible, true); + assert.equal(r.attemptBatch.attempts.length, 2); + }); + + it('cap + additional resolvable token → truncated, batch not metric eligible, no post-cap drafts', async () => { + const { analyzeA2AMentions } = await loadA2A(); + const r = analyzeA2AMentions('@opus @codex @gemini', 'kimi'); + assert.deepEqual(r.mentions, ['opus', 'codex'], 'routing behavior unchanged'); + assert.equal(r.attemptBatch.truncated, true); + assert.equal(r.attemptBatch.metricEligible, false); + assert.deepEqual(outcomes(r.attemptBatch), ['resolved', 'resolved'], 'post-cap tokens get no drafts'); + }); + + it('cap + additional token on a later line → truncated', async () => { + const { analyzeA2AMentions } = await loadA2A(); + const r = analyzeA2AMentions('@opus\n@codex\n@gemini 看下', 'kimi'); + assert.equal(r.attemptBatch.truncated, true); + assert.equal(r.attemptBatch.metricEligible, false); + }); + + it('cap + trailing duplicate-only token → NOT truncated (duplicate is not metric-affecting)', async () => { + const { analyzeA2AMentions } = await loadA2A(); + const r = analyzeA2AMentions('@opus @codex @布偶猫', 'kimi'); + assert.equal(r.attemptBatch.truncated, false); + assert.equal(r.attemptBatch.metricEligible, true); + assert.equal(r.attemptBatch.attempts.length, 2); + }); + + it('cap + trailing unknown token → truncated (metric-affecting token per bias rationale)', async () => { + const { analyzeA2AMentions } = await loadA2A(); + const r = analyzeA2AMentions('@opus @codex @zzzcat', 'kimi'); + assert.equal(r.attemptBatch.truncated, true); + assert.equal(r.attemptBatch.metricEligible, false); + }); + + it('cap + trailing self token → truncated (self_excluded is denominator-eligible)', async () => { + const { analyzeA2AMentions } = await loadA2A(); + const r = analyzeA2AMentions('@opus @codex @kimi', 'kimi'); + assert.equal(r.attemptBatch.truncated, true); + assert.equal(r.attemptBatch.metricEligible, false); + }); +}); + +// --------------------------------------------------------------------------- +// parserMode=user (AgentRouter.parseMentionsRaw) +// --------------------------------------------------------------------------- + +describe('F257 T-A parserMode=user: attempt batch shape', () => { + it('emits drafts for prose mentions with user batch flags', async () => { + const router = await createRouter(); + const r = router.parseMentionsRaw('hello 请 @codex 看下这个问题'); + assert.equal(r.mentions.length, 1); + const batch = r.attemptBatch; + assert.equal(batch.parserMode, 'user'); + assert.equal(batch.spanBasis, 'lowercased_message'); + assert.equal(batch.truncated, false); + assert.equal(batch.metricEligible, true); + assert.deepEqual(outcomes(batch), ['resolved']); + assert.equal(batch.attempts[0].targetCatId, 'codex'); + assert.equal(batch.attempts[0].token, '@codex'); + }); + + it('same span visited by route-line and prose passes yields ONE draft with original outcome', async () => { + const router = await createRouter(); + // sol R7 P1-1 regression: the second traversal must not reclassify the + // token as duplicate — traversal artifact merges silently. + const r = router.parseMentionsRaw('@codex 修一下这个 bug'); + assert.deepEqual(outcomes(r.attemptBatch), ['resolved']); + assert.equal(r.attemptBatch.attempts[0].targetCatId, 'codex'); + }); + + it('duplicate = distinct spans resolving to the same cat', async () => { + const router = await createRouter(); + const r = router.parseMentionsRaw('@codex 先看,然后 @缅因猫 再确认'); + assert.equal(r.mentions.length, 1, 'routing folds to one mention'); + assert.deepEqual(outcomes(r.attemptBatch), ['resolved', 'duplicate']); + assert.equal(r.attemptBatch.attempts[1].targetCatId, 'codex'); + assertOrdinalsSortedBySpan(r.attemptBatch); + }); + + it('unknown handles draft unknown_token per distinct span (warning stays deduped)', async () => { + const router = await createRouter(); + const r = router.parseMentionsRaw('找 @nonexistentcat 帮忙,再找一次 @nonexistentcat'); + assert.equal(r.routing_warnings.length, 1, 'warning dedup unchanged'); + assert.equal(r.routing_warnings[0].kind, 'cat_not_found'); + assert.deepEqual(outcomes(r.attemptBatch), ['unknown_token', 'unknown_token']); + assertOrdinalsSortedBySpan(r.attemptBatch); + }); + + it('disabled cat drafts disabled_cat in user mode', async () => { + const router = await createRouter(); + const r = router.parseMentionsRaw('请 @antigravity 看看'); + assert.equal(r.routing_warnings.length, 1); + assert.equal(r.routing_warnings[0].kind, 'cat_disabled'); + assert.deepEqual(outcomes(r.attemptBatch), ['disabled_cat']); + }); +}); + +describe('F257 T-A parserMode=user: group_keyword_skip (parser 改造④)', () => { + it('route-line @all is classified group_keyword_skip, not unknown_token', async () => { + const router = await createRouter(); + const r = router.parseMentionsRaw('@all 大家集合'); + const groupDrafts = r.attemptBatch.attempts.filter((a) => a.outcome === 'group_keyword_skip'); + assert.equal(groupDrafts.length, 1); + assert.equal(groupDrafts[0].token, '@all'); + assert.equal( + r.attemptBatch.attempts.filter((a) => a.outcome === 'unknown_token').length, + 0, + 'group keyword must not fall through to unknown_token', + ); + }); + + it('mid-prose @全体 is classified group_keyword_skip', async () => { + const router = await createRouter(); + const r = router.parseMentionsRaw('大家注意 @全体 集合了'); + const groupDrafts = r.attemptBatch.attempts.filter((a) => a.outcome === 'group_keyword_skip'); + assert.equal(groupDrafts.length, 1); + assert.equal(groupDrafts[0].token, '@全体'); + }); + + it('group keyword with non-boundary continuation is NOT a group keyword', async () => { + const router = await createRouter(); + const r = router.parseMentionsRaw('看下 @allxyz 这个'); + assert.deepEqual(outcomes(r.attemptBatch), ['unknown_token']); + }); +}); + +describe('F257 T-A parserMode=user: domain_suffixed_skip', () => { + it('cat pattern with domain suffix drafts domain_suffixed_skip', async () => { + const router = await createRouter(); + const r = router.parseMentionsRaw('部署到 @opus.dev 这个域名'); + assert.deepEqual(r.mentions, [], 'routing behavior unchanged'); + assert.deepEqual(outcomes(r.attemptBatch), ['domain_suffixed_skip']); + }); + + it('domain-like unknown handle drafts domain_suffixed_skip (not unknown_token)', async () => { + const router = await createRouter(); + const r = router.parseMentionsRaw('联系 @example.com 这个地址'); + assert.equal(r.routing_warnings.length, 0, 'no warning for domain-like handles (unchanged)'); + assert.deepEqual(outcomes(r.attemptBatch), ['domain_suffixed_skip']); + }); +}); + +describe('F257 T-A parserMode=user: unknown_token Unicode handles (sol R1 P1-2)', () => { + it('CJK unknown handle emits exactly one unknown_token draft + warning', async () => { + const router = await createRouter(); + const r = router.parseMentionsRaw('请 @不存在的猫 看看'); + assert.deepEqual(outcomes(r.attemptBatch), ['unknown_token']); + assert.equal(r.attemptBatch.attempts[0].token, '@不存在的猫'); + assert.ok(r.routing_warnings.length >= 1, 'unknown handle must surface a routing warning'); + }); + + it('CJK unknown handle terminates at CJK punctuation boundary', async () => { + const router = await createRouter(); + const r = router.parseMentionsRaw('@幽灵猫,在吗'); + assert.deepEqual(outcomes(r.attemptBatch), ['unknown_token']); + assert.equal(r.attemptBatch.attempts[0].token, '@幽灵猫'); + }); + + it('regression: ASCII unknown / domain-shaped / email behavior unchanged', async () => { + const router = await createRouter(); + const ascii = router.parseMentionsRaw('找 @nonexistentcat 帮忙'); + assert.deepEqual(outcomes(ascii.attemptBatch), ['unknown_token']); + const domain = router.parseMentionsRaw('联系 @example.com 这个地址'); + assert.deepEqual(outcomes(domain.attemptBatch), ['domain_suffixed_skip']); + const email = router.parseMentionsRaw('发邮件到 someone@example.com 即可'); + assert.deepEqual(outcomes(email.attemptBatch), [], 'email address is excluded upstream'); + }); +}); + +describe('F257 T-A parserMode=user: speech alias pass span mapping', () => { + // P1-4: @宪宪/@砚砚 removed from opus/codex breed patterns → use @布偶猫/@缅因猫 + it('speech-only alias drafts one resolved attempt mapped to raw coordinates', async () => { + const router = await createRouter(); + const r = router.parseMentionsRaw('at 布偶猫 帮个忙'); + assert.equal(r.mentions.length, 1); + assert.deepEqual(outcomes(r.attemptBatch), ['resolved']); + assert.equal(r.attemptBatch.attempts[0].targetCatId, 'opus'); + // Raw region "at 布偶猫" = [0, 6) in the original (lowercased) message. + assert.deepEqual(r.attemptBatch.attempts[0].span, { start: 0, end: 6 }); + }); + + it('speech pass re-scan of shifted regular tokens merges into existing drafts (no double count)', async () => { + const router = await createRouter(); + // Speech replacement before @opus shifts positions in the speech variant; + // the re-scan must map back to raw coordinates and merge, not double-draft. + const r = router.parseMentionsRaw('at 缅因猫 先看\n@opus 你好'); + assert.equal(r.attemptBatch.attempts.length, 2, 'exactly one draft per physical token'); + const byCat = Object.fromEntries(r.attemptBatch.attempts.map((a) => [a.targetCatId, a.outcome])); + assert.deepEqual(byCat, { codex: 'resolved', opus: 'resolved' }); + assertOrdinalsSortedBySpan(r.attemptBatch); + }); +}); + +describe('F257 T-A parserMode=user: plumbing through resolveTargetsAndIntent', () => { + it('resolveTargetsAndIntent exposes the user attempt batch', async () => { + const router = await createRouter(); + const result = await router.resolveTargetsAndIntent('@codex 看下', 'thread-f257-v1'); + assert.ok(result.attemptBatch, 'attemptBatch must be plumbed through'); + assert.equal(result.attemptBatch.parserMode, 'user'); + assert.deepEqual(outcomes(result.attemptBatch), ['resolved']); + }); + + it('group-mention path still carries the individual attempt batch (group keyword drafted, expansion not drafted)', async () => { + const router = await createRouter(); + const result = await router.resolveTargetsAndIntent('@all 集合', 'thread-f257-v1'); + assert.ok(result.attemptBatch); + const kinds = result.attemptBatch.attempts.map((a) => a.outcome); + assert.ok(kinds.includes('group_keyword_skip')); + assert.equal( + result.attemptBatch.attempts.filter((a) => a.outcome === 'resolved').length, + 0, + 'group expansion targets must NOT appear as resolved attempts (group mention exits V1)', + ); + }); +}); + +// --------------------------------------------------------------------------- +// Metric mapping (T-A eligible / success columns as pure functions) +// --------------------------------------------------------------------------- + +describe('F257 T-A metric mapping functions', () => { + it('eligible column: resolved/disabled_cat/self_excluded/unknown_token enter the denominator', async () => { + const { isMetricEligibleOutcome } = await loadAttemptModule(); + for (const o of ['resolved', 'disabled_cat', 'self_excluded', 'unknown_token', 'ambiguous']) { + assert.equal(isMetricEligibleOutcome(o), true, `${o} must be denominator-eligible`); + } + for (const o of ['duplicate', 'group_keyword_skip', 'domain_suffixed_skip']) { + assert.equal(isMetricEligibleOutcome(o), false, `${o} must NOT be denominator-eligible`); + } + }); + + it('success column: only resolved counts as success', async () => { + const { isSuccessOutcome } = await loadAttemptModule(); + assert.equal(isSuccessOutcome('resolved'), true); + for (const o of [ + 'disabled_cat', + 'self_excluded', + 'unknown_token', + 'duplicate', + 'group_keyword_skip', + 'domain_suffixed_skip', + 'ambiguous', + ]) { + assert.equal(isSuccessOutcome(o), false); + } + }); +}); + +// --------------------------------------------------------------------------- +// Batch validator cross-field invariants (sol R2 P1-2) +// --------------------------------------------------------------------------- + +describe('F257 T-A batch validator: cross-field invariants (sol R2 P1-2)', () => { + async function loadValidator() { + const mod = await import(`${ROUTING_DIR}/routing-attempt.js`); + return mod.isValidRoutingAttemptBatch; + } + + function validBatch(overrides = {}) { + return { + parserMode: 'a2a', + spanBasis: 'a2a_normalized', + truncated: false, + metricEligible: true, + attempts: [ + { tokenOrdinal: 0, outcome: 'resolved', token: '@opus', span: { start: 0, end: 5 }, targetCatId: 'opus' }, + { tokenOrdinal: 1, outcome: 'unknown_token', token: '@zzz', span: { start: 6, end: 10 } }, + ], + ...overrides, + }; + } + + it('accepts a well-formed batch and REAL parser output (round-trip sanity)', async () => { + const isValid = await loadValidator(); + assert.equal(isValid(validBatch()), true); + + const { analyzeA2AMentions } = await loadA2A(); + const a2a = analyzeA2AMentions('@opus @不存在 请看\n@codex 收尾', 'kimi').attemptBatch; + assert.equal(isValid(a2a), true, 'a2a parser output must pass its own validator'); + + const router = await createRouter(); + const user = router.parseMentionsRaw('请 @codex 看,@all 集合,@幽灵猫 呢,邮箱 a@b.com').attemptBatch; + assert.equal(isValid(user), true, 'user parser output must pass its own validator'); + }); + + it('rejects sol R2 repro: truncated+eligible + resolved without target + loose ordinal', async () => { + const isValid = await loadValidator(); + assert.equal( + isValid({ + parserMode: 'a2a', + spanBasis: 'a2a_normalized', + truncated: true, + metricEligible: true, + attempts: [{ tokenOrdinal: 9, outcome: 'resolved', token: '@opus', span: { start: 0, end: 5 } }], + }), + false, + ); + }); + + it('rejects each invariant violation individually', async () => { + const isValid = await loadValidator(); + // metricEligible must equal !truncated + assert.equal(isValid(validBatch({ truncated: true })), false); + assert.equal(isValid(validBatch({ metricEligible: false })), false); + // user parser has no cap — truncated user batch cannot exist + assert.equal( + isValid( + validBatch({ parserMode: 'user', spanBasis: 'lowercased_message', truncated: true, metricEligible: false }), + ), + false, + ); + // tokenOrdinal must be 0-based consecutive + assert.equal( + isValid( + validBatch({ + attempts: [ + { tokenOrdinal: 1, outcome: 'resolved', token: '@opus', span: { start: 0, end: 5 }, targetCatId: 'opus' }, + ], + }), + ), + false, + ); + // spans must be strictly increasing by (start, end) + assert.equal( + isValid( + validBatch({ + attempts: [ + { tokenOrdinal: 0, outcome: 'resolved', token: '@opus', span: { start: 6, end: 11 }, targetCatId: 'opus' }, + { tokenOrdinal: 1, outcome: 'unknown_token', token: '@zzz', span: { start: 0, end: 4 } }, + ], + }), + ), + false, + ); + // sol R3 P1-3: parserMode↔spanBasis pairing is fixed by the finalize call sites + assert.equal(isValid(validBatch({ spanBasis: 'lowercased_message' })), false); + // sol R3 P1-3: a present-but-empty target must not enter the exact numerator + assert.equal( + isValid( + validBatch({ + attempts: [ + { tokenOrdinal: 0, outcome: 'resolved', token: '@opus', span: { start: 0, end: 5 }, targetCatId: '' }, + ], + }), + ), + false, + ); + // sol R3 P1-3: spans are non-overlapping in scan order + assert.equal( + isValid( + validBatch({ + attempts: [ + { tokenOrdinal: 0, outcome: 'resolved', token: '@opus', span: { start: 0, end: 5 }, targetCatId: 'opus' }, + { tokenOrdinal: 1, outcome: 'unknown_token', token: '@zz', span: { start: 3, end: 8 } }, + ], + }), + ), + false, + ); + // pattern-matched outcomes carry a target; token-skip outcomes never do + assert.equal( + isValid( + validBatch({ + attempts: [{ tokenOrdinal: 0, outcome: 'resolved', token: '@opus', span: { start: 0, end: 5 } }], + }), + ), + false, + ); + assert.equal( + isValid( + validBatch({ + attempts: [ + { + tokenOrdinal: 0, + outcome: 'unknown_token', + token: '@zzz', + span: { start: 0, end: 4 }, + targetCatId: 'opus', + }, + ], + }), + ), + false, + ); + }); +}); + +describe('F257 sol R4 P1-1: provenance write boundary fails closed', () => { + async function loadStorePort() { + return import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + } + const legalBatch = { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }; + + it('routedProvenance throws when a parser lane omits its batch (P1-1a)', async () => { + const { routedProvenance } = await loadStorePort(); + assert.throws(() => routedProvenance('user', undefined), /requires the parser attempt batch/); + }); + + it('routedProvenance wraps a legal batch as a routed declaration', async () => { + const { routedProvenance } = await loadStorePort(); + const frag = routedProvenance('user', legalBatch); + assert.equal(frag.provenance.routed, true); + assert.equal(frag.provenance.author, 'user'); + assert.equal(frag.provenance.observation, 'original'); + assert.equal(frag.routingFact, legalBatch); + }); + + it('assertProvenanceConsistent rejects a missing declaration (P1-1b)', async () => { + const { assertProvenanceConsistent } = await loadStorePort(); + assert.throws(() => assertProvenanceConsistent({ catId: null }), /append requires provenance/); + assert.throws( + () => assertProvenanceConsistent({ provenance: undefined, catId: 'opus' }), + /append requires provenance/, + ); + }); + + it('assertProvenanceConsistent rejects out-of-domain author and non-boolean routed (P1-1b)', async () => { + const { assertProvenanceConsistent } = await loadStorePort(); + assert.throws( + () => + assertProvenanceConsistent({ + provenance: { author: 'ghost', routed: false, observation: 'original' }, + catId: null, + }), + /author must be one of/, + ); + assert.throws( + () => + assertProvenanceConsistent({ + provenance: { author: 'user', routed: 'yes', observation: 'original' }, + catId: null, + }), + /routed must be a boolean/, + ); + }); + + it("author 'unknown' carries no catId constraint (P1-2 legacy copy lane)", async () => { + const { assertProvenanceConsistent } = await loadStorePort(); + assert.doesNotThrow(() => + assertProvenanceConsistent({ + provenance: { author: 'unknown', routed: false, observation: 'original' }, + catId: null, + }), + ); + assert.doesNotThrow(() => + assertProvenanceConsistent({ + provenance: { author: 'unknown', routed: false, observation: 'original' }, + catId: 'opus', + }), + ); + }); + + it('R6: authenticated operator and external connector authors are disjoint at the write boundary', async () => { + const { assertProvenanceConsistent, isAuthenticatedOperatorMessage } = await loadStorePort(); + const connectorSource = { connector: 'telegram', label: 'Telegram', icon: 'telegram' }; + + assert.throws( + () => + assertProvenanceConsistent({ + provenance: { author: 'user', routed: false, observation: 'original' }, + catId: null, + source: connectorSource, + }), + /authenticated operator.*source/, + ); + assert.throws( + () => + assertProvenanceConsistent({ + provenance: { author: 'external_user', routed: false, observation: 'original' }, + catId: null, + }), + /external_user.*source/, + ); + assert.doesNotThrow(() => + assertProvenanceConsistent({ + provenance: { author: 'external_user', routed: false, observation: 'original' }, + catId: null, + source: connectorSource, + }), + ); + + assert.equal( + isAuthenticatedOperatorMessage({ + provenance: { author: 'user', routed: false, observation: 'original' }, + catId: null, + }), + true, + ); + assert.equal( + isAuthenticatedOperatorMessage({ + provenance: { author: 'external_user', routed: false, observation: 'original' }, + catId: null, + source: connectorSource, + }), + false, + ); + assert.equal( + isAuthenticatedOperatorMessage({ + provenance: { author: 'user', routed: false, observation: 'derived', sourceRef: 'message:old' }, + catId: null, + }), + false, + 'derived context is not a fresh authenticated operator assertion', + ); + }); + + it('requires explicit observation lineage and a sourceRef for derived copies', async () => { + const { assertProvenanceConsistent } = await loadStorePort(); + assert.throws( + () => + assertProvenanceConsistent({ + provenance: { author: 'user', routed: false }, + catId: null, + }), + /provenance\.observation must be one of original\|derived/, + ); + assert.throws( + () => + assertProvenanceConsistent({ + provenance: { author: 'user', routed: false, observation: 'derived' }, + catId: null, + }), + /derived provenance requires a non-empty sourceRef/, + ); + assert.throws( + () => + assertProvenanceConsistent({ + provenance: { + author: 'user', + routed: false, + observation: 'original', + sourceRef: 'message:source-1', + }, + catId: null, + }), + /original provenance must not carry sourceRef/, + ); + }); +}); diff --git a/packages/api/test/f257-signature-lint-redis-roundtrip.test.js b/packages/api/test/f257-signature-lint-redis-roundtrip.test.js new file mode 100644 index 0000000000..e6472a207a --- /dev/null +++ b/packages/api/test/f257-signature-lint-redis-roundtrip.test.js @@ -0,0 +1,54 @@ +/** + * F257 #4 (sol R1 P1-2) — signatureLint MUST survive the Redis serialize→parse + * round-trip. + * + * Bug: `serializeExtra` writes the whole object (so signatureLint is stored), + * but `safeParseExtra` rebuilds `extra` from an explicit allowlist and silently + * dropped `signatureLint` → the field was in-process-only; any Redis-backed + * reload/hydration lost it, violating the detection-layer persistence contract. + * The in-memory MessageStore callback tests never caught this because they + * inspect the immediate append result, not the Redis read path. + */ + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +const { serializeExtra, safeParseExtra } = await import( + '../dist/domains/cats/services/stores/redis/redis-message-parsers.js' +); + +function roundTrip(extra) { + return safeParseExtra(serializeExtra(extra)); +} + +describe('F257 #4 (sol R1 P1-2) — safeParseExtra preserves signatureLint', () => { + it('signed:false round-trips', () => { + assert.deepEqual(roundTrip({ signatureLint: { signed: false } }), { signatureLint: { signed: false } }); + }); + + it('signed:true round-trips', () => { + assert.deepEqual(roundTrip({ signatureLint: { signed: true } }), { signatureLint: { signed: true } }); + }); + + it('coexists with other extra fields (no cross-contamination)', () => { + assert.deepEqual(roundTrip({ isExplicitPost: true, signatureLint: { signed: false } }), { + isExplicitPost: true, + signatureLint: { signed: false }, + }); + }); + + it('malformed signatureLint.signed (non-boolean) is dropped, other fields survive', () => { + // signed: 'no' is not a boolean → signatureLint dropped; isExplicitPost preserved. + const parsed = safeParseExtra(JSON.stringify({ isExplicitPost: true, signatureLint: { signed: 'no' } })); + assert.deepEqual(parsed, { isExplicitPost: true }); + }); + + it('non-object signatureLint is dropped', () => { + const parsed = safeParseExtra(JSON.stringify({ isExplicitPost: true, signatureLint: 'x' })); + assert.deepEqual(parsed, { isExplicitPost: true }); + }); + + it('signatureLint-only with malformed shape → whole extra undefined (no phantom field)', () => { + assert.equal(safeParseExtra(JSON.stringify({ signatureLint: { signed: 1 } })), undefined); + }); +}); diff --git a/packages/api/test/f257-signature-lint-stream-final.test.js b/packages/api/test/f257-signature-lint-stream-final.test.js new file mode 100644 index 0000000000..1f9de58941 --- /dev/null +++ b/packages/api/test/f257-signature-lint-stream-final.test.js @@ -0,0 +1,139 @@ +/** + * F257 #4 (sol R1 P1-1) — stream-final signature-lint coverage. + * + * The detection layer must stamp `extra.signatureLint` on ORDINARY agent final + * messages (persisted by route-serial / route-parallel with `origin:'stream'`), + * not just explicit callback `post_message` posts. Otherwise a cat that never + * calls post_message is absent from the sign-rate denominator → systematic bias. + * These integration tests drive the real routeSerial/routeParallel generators + * and assert the stream-final append carries the signed/unsigned verdict. + */ + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +const SIGNED_FINAL = 'Review done, all green.\n\n[宪宪/claude-opus-4-8🐾]'; +const UNSIGNED_FINAL = 'Review done, all green.'; + +function createMockService(catId, text, innerInvocationId = `cli-${catId}`) { + return { + async *invoke() { + yield { + type: 'system_info', + catId, + content: JSON.stringify({ type: 'invocation_created', invocationId: innerInvocationId }), + timestamp: Date.now(), + }; + yield { type: 'text', catId, content: text, timestamp: Date.now() }; + yield { type: 'done', catId, timestamp: Date.now() }; + }, + }; +} + +function createMockDeps(services, appendCalls) { + let invocationSeq = 0; + let messageSeq = 0; + const storedById = new Map(); + return { + services, + invocationDeps: { + registry: { + create: () => ({ invocationId: `inv-${++invocationSeq}`, callbackToken: `tok-${invocationSeq}` }), + verify: () => null, + }, + sessionManager: { + getOrCreate: async () => ({}), + get: async () => null, + resolveWorkingDirectory: () => '/tmp/test', + }, + threadStore: null, + apiUrl: 'http://127.0.0.1:3004', + }, + messageStore: { + append: async (msg) => { + const stored = { id: `msg-${++messageSeq}`, ...msg, threadId: msg.threadId ?? 'default' }; + storedById.set(stored.id, stored); + appendCalls.push(msg); + return stored; + }, + getById: async (id) => storedById.get(id) ?? null, + getRecent: () => [], + getMentionsFor: () => [], + getRecentMentionsFor: () => [], + getBefore: () => [], + getByThread: () => [], + getByThreadAfter: () => [], + getByThreadBefore: () => [], + augmentStreamMetadata: async () => ({}), + }, + socketManager: { broadcastToRoom: () => {} }, + draftStore: { + upsert: () => {}, + touch: () => {}, + delete: () => Promise.resolve(), + deleteByThread: () => {}, + getByThread: () => [], + }, + voiceMode: false, + }; +} + +function streamFinal(appendCalls, catId) { + return appendCalls.find((m) => m.origin === 'stream' && m.catId === catId); +} + +describe('F257 #4 (sol R1 P1-1) — routeSerial stream-final signature lint', () => { + it('signed final → stream append carries extra.signatureLint.signed=true', async () => { + const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); + const appendCalls = []; + const deps = createMockDeps({ opus: createMockService('opus', SIGNED_FINAL) }, appendCalls); + for await (const _msg of routeSerial(deps, ['opus'], 'hi', 'user1', 'thread1')) { + /* drain */ + } + const finalMsg = streamFinal(appendCalls, 'opus'); + assert.ok(finalMsg, 'serial stream-final persisted'); + assert.deepEqual(finalMsg.extra?.signatureLint, { signed: true }); + }); + + it('unsigned final → stream append carries extra.signatureLint.signed=false (enters denominator)', async () => { + const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); + const appendCalls = []; + const deps = createMockDeps({ opus: createMockService('opus', UNSIGNED_FINAL) }, appendCalls); + for await (const _msg of routeSerial(deps, ['opus'], 'hi', 'user1', 'thread1')) { + /* drain */ + } + const finalMsg = streamFinal(appendCalls, 'opus'); + assert.ok(finalMsg, 'serial stream-final persisted'); + assert.deepEqual(finalMsg.extra?.signatureLint, { signed: false }); + }); +}); + +describe('F257 #4 (sol R1 P1-1) — routeParallel stream-final signature lint', () => { + it('signed final → stream append carries extra.signatureLint.signed=true', async () => { + const { routeParallel } = await import('../dist/domains/cats/services/agents/routing/route-parallel.js'); + const appendCalls = []; + const deps = createMockDeps({ opus: createMockService('opus', SIGNED_FINAL) }, appendCalls); + for await (const _msg of routeParallel(deps, ['opus'], 'hi', 'user1', 'thread1', { + parentInvocationId: 'parent-p1-signed', + })) { + /* drain */ + } + const finalMsg = streamFinal(appendCalls, 'opus'); + assert.ok(finalMsg, 'parallel stream-final persisted'); + assert.deepEqual(finalMsg.extra?.signatureLint, { signed: true }); + }); + + it('unsigned final → stream append carries extra.signatureLint.signed=false (enters denominator)', async () => { + const { routeParallel } = await import('../dist/domains/cats/services/agents/routing/route-parallel.js'); + const appendCalls = []; + const deps = createMockDeps({ opus: createMockService('opus', UNSIGNED_FINAL) }, appendCalls); + for await (const _msg of routeParallel(deps, ['opus'], 'hi', 'user1', 'thread1', { + parentInvocationId: 'parent-p1-unsigned', + })) { + /* drain */ + } + const finalMsg = streamFinal(appendCalls, 'opus'); + assert.ok(finalMsg, 'parallel stream-final persisted'); + assert.deepEqual(finalMsg.extra?.signatureLint, { signed: false }); + }); +}); diff --git a/packages/api/test/f257-signature-lint.test.js b/packages/api/test/f257-signature-lint.test.js new file mode 100644 index 0000000000..0520f4604d --- /dev/null +++ b/packages/api/test/f257-signature-lint.test.js @@ -0,0 +1,145 @@ +/** + * F257 修复清单 #4 — message-signature structural lint (O2→O1), detection layer. + * + * 真相源:docs/features/F257-harness-ledger.md L198 + governance-l0「用自己的身份 + * 签名 [昵称/模型🐾],签名必须含模型型号」。`lintCatSignature` 是 COMPLIANCE lint: + * 断言消息末行是否为**当前契约形态** `[nickname/model🐾]`(nickname + '/' + model + * + 🐾)。 + * + * STRICTNESS(sol R1 P1-3):不复用 `isCatSignatureLine`(routing 的 permissive + * STRIP matcher,容忍 `[Spark🐾]` 无模型、`[砚砚/GPT-5.5]` 无爪)——那会把无模型/ + * 无爪签名误判为 compliant(false negative)。strip=permissive(routing) 与 + * lint=strict(compliance) 分离。presence-only(契约 SHAPE 在场),identity- + * correctness(签名匹配发帖猫)仍 deferred。 + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { + lintCatSignature, + signatureLintExtra, +} from '../dist/domains/cats/services/agents/routing/cat-signature-lint.js'; + +describe('F257 #4 — lintCatSignature (strict [nickname/model🐾] compliance lint)', () => { + // --- 正例:契约形态 nickname/model🐾 --- + test('契约形态 [宪宪/claude-opus-4-8🐾] → signed,返回 trimmed 签名行', () => { + const r = lintCatSignature('Some review text.\n\n[宪宪/claude-opus-4-8🐾]'); + assert.equal(r.signed, true); + assert.equal(r.signatureLine, '[宪宪/claude-opus-4-8🐾]'); + }); + + test('契约形态 [烁烁/Gemini-25🐾](模型含 dash)→ signed', () => { + assert.equal(lintCatSignature('done\n\n[烁烁/Gemini-25🐾]').signed, true); + }); + + test('契约形态 [砚砚/gpt-5.6-sol🐾](模型含 dash+dot)→ signed', () => { + assert.equal(lintCatSignature('merged\n[砚砚/gpt-5.6-sol🐾]').signed, true); + }); + + test('整条消息就是一个契约签名 → signed', () => { + assert.equal(lintCatSignature('[宪宪/claude-opus-4-8🐾]').signed, true); + }); + + // --- P1-3:strip matcher 容忍但契约不合规的形态 → NOT signed --- + test('P1-3: pawed slashless [Spark🐾](无模型型号)→ NOT signed', () => { + assert.equal(lintCatSignature('done\n\n[Spark🐾]').signed, false); + }); + + test('P1-3: pawed slashless [烁烁🐾](无模型型号)→ NOT signed', () => { + assert.equal(lintCatSignature('x\n[烁烁🐾]').signed, false); + }); + + test('P1-3: legacy 无爪 slashed [砚砚/GPT-5.5](缺 🐾)→ NOT signed', () => { + assert.equal(lintCatSignature('merged\n[砚砚/GPT-5.5]').signed, false); + }); + + // --- walk 逻辑:跳过 trailing 空行 / 行内空白 / \r\n --- + test('契约签名后有 trailing 空行 → 仍 signed(跳过空行)', () => { + assert.equal(lintCatSignature('text\n[烁烁/Gemini-25🐾]\n\n \n').signed, true); + }); + + test('契约签名行含前后空白 → signed,signatureLine 已 trim', () => { + const r = lintCatSignature('text\n [宪宪/Opus-46🐾] '); + assert.equal(r.signed, true); + assert.equal(r.signatureLine, '[宪宪/Opus-46🐾]'); + }); + + test('\\r\\n 换行 → 正确 walk', () => { + assert.equal(lintCatSignature('line1\r\nline2\r\n[砚砚/Codex🐾]\r\n').signed, true); + }); + + // --- 反例:契约签名非末尾(其后还有内容行)--- + test('契约签名后还有内容行 → NOT signed(必须 trailing)', () => { + const r = lintCatSignature('[宪宪/claude-opus-4-8🐾]\n\nPS: one more thing.'); + assert.equal(r.signed, false); + assert.equal(r.signatureLine, null); + }); + + // --- 反例:完全没有签名(dev-7a882ba0 漏签类)--- + test('普通消息无签名 → not signed', () => { + assert.equal(lintCatSignature('LGTM, merging now.').signed, false); + }); + + test('空串 → not signed', () => { + const r = lintCatSignature(''); + assert.equal(r.signed, false); + assert.equal(r.signatureLine, null); + }); + + test('纯空白 → not signed', () => { + assert.equal(lintCatSignature(' \n\n ').signed, false); + }); + + // --- 反例:非签名形态 --- + test('正文 token [Phase B] → not signed', () => { + assert.equal(lintCatSignature('Update:\n[Phase B]').signed, false); + }); + + test('括号文件路径 [packages/api/src/foo.ts] → not signed', () => { + assert.equal(lintCatSignature('see\n[packages/api/src/foo.ts]').signed, false); + }); + + // sol R4 P1: model may be PROVIDER-QUALIFIED (contains '/'); first slash delimits. + test('provider-qualified [金渐层/codex-for-me/gpt-5.4🐾](opencode roster 实锤)→ signed', () => { + const r = lintCatSignature('done\n[金渐层/codex-for-me/gpt-5.4🐾]'); + assert.equal(r.signed, true); + assert.equal(r.signatureLine, '[金渐层/codex-for-me/gpt-5.4🐾]'); + }); + + test('multi-segment model [a/b/c🐾] → signed(first slash 分隔,model=b/c)', () => { + assert.equal(lintCatSignature('x\n[a/b/c🐾]').signed, true); + }); + + test('sol R4 P1: 空白 nickname [ /gpt-5.6-sol🐾] → not signed(trim 后非空必需)', () => { + assert.equal(lintCatSignature('x\n[ /gpt-5.6-sol🐾]').signed, false); + }); + + test('sol R4 P1: 空白 model [砚砚/ 🐾] → not signed(trim 后非空必需)', () => { + assert.equal(lintCatSignature('x\n[砚砚/ 🐾]').signed, false); + }); +}); + +describe('F257 #4 — signatureLintExtra (post-seam extra projection)', () => { + test('契约签名消息 → { signatureLint: { signed: true } }', () => { + assert.deepEqual(signatureLintExtra('done\n\n[宪宪/claude-opus-4-8🐾]'), { + signatureLint: { signed: true }, + }); + }); + + test('无签名 text 消息 → { signatureLint: { signed: false } }', () => { + assert.deepEqual(signatureLintExtra('LGTM, merging now.'), { + signatureLint: { signed: false }, + }); + }); + + test('非契约签名 [Spark🐾] → { signatureLint: { signed: false } }', () => { + assert.deepEqual(signatureLintExtra('done\n[Spark🐾]'), { + signatureLint: { signed: false }, + }); + }); + + test('blank/whitespace content → {} (pure-media exclusion, out of denominator)', () => { + assert.deepEqual(signatureLintExtra(''), {}); + assert.deepEqual(signatureLintExtra(' \n\n '), {}); + }); +}); diff --git a/packages/api/test/game-command-bridge.test.js b/packages/api/test/game-command-bridge.test.js index 30a31549af..a8a463266d 100644 --- a/packages/api/test/game-command-bridge.test.js +++ b/packages/api/test/game-command-bridge.test.js @@ -87,7 +87,17 @@ function createStubRouter() { }, async resolveTargetsAndIntent() { routeCalled = true; - return { targetCats: ['opus'], intent: { intent: 'execute', explicit: false, promptTags: [] } }; + return { + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, + targetCats: ['opus'], + intent: { intent: 'execute', explicit: false, promptTags: [] }, + }; }, async *routeExecution() { routeCalled = true; diff --git a/packages/api/test/game-phase-h-fixes.test.js b/packages/api/test/game-phase-h-fixes.test.js index bb09184f6c..ce8ce257b8 100644 --- a/packages/api/test/game-phase-h-fixes.test.js +++ b/packages/api/test/game-phase-h-fixes.test.js @@ -289,7 +289,17 @@ describe('Phase H P1 Fixes — definition-level regression guards', () => { socketManager: { broadcastToRoom() {}, emitToUser() {}, broadcastAgentMessage() {} }, router: { async resolveTargetsAndIntent() { - return { targetCats: ['opus'], intent: { intent: 'execute', explicit: false, promptTags: [] } }; + return { + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, + targetCats: ['opus'], + intent: { intent: 'execute', explicit: false, promptTags: [] }, + }; }, async *routeExecution() { yield { type: 'done', catId: 'opus', timestamp: Date.now() }; diff --git a/packages/api/test/get-message-visibility.test.js b/packages/api/test/get-message-visibility.test.js index 029597657e..eef18848c2 100644 --- a/packages/api/test/get-message-visibility.test.js +++ b/packages/api/test/get-message-visibility.test.js @@ -71,6 +71,7 @@ describe('GET /api/callbacks/get-message visibility', () => { // Create a whisper visible only to 'codex', not 'opus' const whisperMsg = messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'secret whisper', @@ -100,6 +101,7 @@ describe('GET /api/callbacks/get-message visibility', () => { const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); const thread = threadStore.create('user-1', 'system test'); const sysMsg = messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, userId: 'system', catId: null, content: 'SYSTEM BADGE — internal', @@ -120,6 +122,7 @@ describe('GET /api/callbacks/get-message visibility', () => { const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); const thread = threadStore.create('user-1', 'briefing test'); const briefingMsg = messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'TOP SECRET BRIEFING', @@ -141,6 +144,7 @@ describe('GET /api/callbacks/get-message visibility', () => { const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); const thread = threadStore.create('user-1', 'context test'); const target = messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'normal target', @@ -149,6 +153,7 @@ describe('GET /api/callbacks/get-message visibility', () => { threadId: thread.id, }); messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, userId: 'system', catId: null, content: 'SYSTEM BADGE neighbor', @@ -157,6 +162,7 @@ describe('GET /api/callbacks/get-message visibility', () => { threadId: thread.id, }); messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'TOP SECRET BRIEFING neighbor', @@ -182,6 +188,7 @@ describe('GET /api/callbacks/get-message visibility', () => { // Debug mode (default) — cats see everything like the user const whisperMsg = messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'whisper for codex', @@ -211,6 +218,7 @@ describe('GET /api/callbacks/get-message visibility', () => { // Message belongs to user-2 const otherUserMsg = messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-2', catId: null, content: 'other user message', @@ -236,6 +244,7 @@ describe('GET /api/callbacks/get-message visibility', () => { const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); const msg = messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'hello opus', @@ -264,6 +273,7 @@ describe('GET /api/callbacks/get-message visibility', () => { const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); const longContent = 'X'.repeat(500); const msg = messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: longContent, @@ -306,6 +316,7 @@ describe('GET /api/callbacks/get-message visibility', () => { const { invocationId, callbackToken } = await registry.create('user-1', 'opus'); const whisperMsg = messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'whisper for opus', @@ -340,6 +351,7 @@ describe('GET /api/callbacks/get-message visibility', () => { // Public message (the target) const target = messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'public target', @@ -350,6 +362,7 @@ describe('GET /api/callbacks/get-message visibility', () => { // Whisper before target — addressed to codex, NOT opus messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'secret for codex only', @@ -362,6 +375,7 @@ describe('GET /api/callbacks/get-message visibility', () => { // Public message after target — should appear in context messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'public after', @@ -397,6 +411,7 @@ describe('GET /api/callbacks/get-message visibility', () => { // codex's stream message in that thread const streamMsg = messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'codex', content: 'codex stream thinking', @@ -427,6 +442,7 @@ describe('GET /api/callbacks/get-message visibility', () => { // opus's own stream message const ownStream = messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'opus', content: 'opus stream thinking', @@ -457,6 +473,7 @@ describe('GET /api/callbacks/get-message visibility', () => { // Target: user message (visible) const target = messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'user question', @@ -467,6 +484,7 @@ describe('GET /api/callbacks/get-message visibility', () => { // codex stream in same thread — should be hidden from opus messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'codex', content: 'codex secret stream', @@ -478,6 +496,7 @@ describe('GET /api/callbacks/get-message visibility', () => { // opus's own stream — should be visible messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'opus', content: 'opus own stream', diff --git a/packages/api/test/guard-rejection-event-log.test.js b/packages/api/test/guard-rejection-event-log.test.js new file mode 100644 index 0000000000..83c3ef1857 --- /dev/null +++ b/packages/api/test/guard-rejection-event-log.test.js @@ -0,0 +1,308 @@ +/** + * F257 Phase A Line B — GuardRejectionEventLog tests + * + * Verifies ZSET-based event log: append, queryWindow, countByGuard, + * fail-open behavior, and 7-day retention pruning. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +// ── FakeRedis with sorted set support ── + +class FakeRedis { + constructor() { + this.sorted = new Map(); // key → Map + } + + async zadd(key, score, member) { + const set = this.sorted.get(key) ?? new Map(); + set.set(member, score); + this.sorted.set(key, set); + return 1; + } + + async zrangebyscore(key, min, max) { + const set = this.sorted.get(key); + if (!set) return []; + return [...set.entries()] + .filter(([, s]) => s >= min && s <= max) + .sort((a, b) => a[1] - b[1]) + .map(([m]) => m); + } + + async zremrangebyscore(key, min, max) { + const set = this.sorted.get(key); + if (!set) return 0; + let removed = 0; + for (const [member, score] of set) { + if (score >= min && score <= max) { + set.delete(member); + removed++; + } + } + return removed; + } + + async zcount(key, min, max) { + const set = this.sorted.get(key); + if (!set) return 0; + let count = 0; + for (const [, score] of set) { + if (score >= min && score <= max) count++; + } + return count; + } +} + +// ── Throwing FakeRedis for fail-open tests ── + +class ThrowingRedis { + async zadd() { + throw new Error('Redis connection lost'); + } + async zrangebyscore() { + throw new Error('Redis connection lost'); + } + async zremrangebyscore() { + throw new Error('Redis connection lost'); + } + async zcount() { + throw new Error('Redis connection lost'); + } +} + +// ── Test helpers ── + +function makeEvent(overrides = {}) { + return { + eventId: `evt-${Math.random().toString(36).slice(2, 10)}`, + kind: 'http_rate_limit', + threadId: 'thread-1', + catId: 'cat-1', + guardId: 'hold_ball_rate_limit', + timestamp: Date.now(), + correlationConfidence: 'window', + currentCount: 5, + maxAllowed: 3, + windowMs: 60000, + ...overrides, + }; +} + +function makeBlockEvent(overrides = {}) { + return { + eventId: `evt-${Math.random().toString(36).slice(2, 10)}`, + kind: 'route_decision_block', + threadId: 'thread-2', + catId: 'cat-2', + guardId: 'a2a_block_pingpong', + timestamp: Date.now(), + correlationConfidence: 'window', + fromCatId: 'cat-2', + targetCatId: 'cat-3', + streakCount: 4, + ...overrides, + }; +} + +describe('GuardRejectionEventLog', async () => { + // Dynamic import — ESM module + const { GuardRejectionEventLog } = await import('../dist/infrastructure/harness-eval/GuardRejectionEventLog.js'); + + test('append stores event and queryWindow retrieves it', async () => { + const redis = new FakeRedis(); + const log = new GuardRejectionEventLog(redis); + const ts = Date.now(); + + const event = makeEvent({ timestamp: ts }); + await log.append(event); + + const results = await log.queryWindow({ since: ts - 1, until: ts + 1 }); + assert.equal(results.length, 1); + assert.equal(results[0].kind, 'http_rate_limit'); + assert.equal(results[0].guardId, 'hold_ball_rate_limit'); + assert.equal(results[0].currentCount, 5); + }); + + test('queryWindow filters by guardId', async () => { + const redis = new FakeRedis(); + const log = new GuardRejectionEventLog(redis); + const ts = Date.now(); + + await log.append(makeEvent({ timestamp: ts, guardId: 'guard-a' })); + await log.append(makeBlockEvent({ timestamp: ts + 1, guardId: 'guard-b' })); + + const filtered = await log.queryWindow({ since: ts - 1, until: ts + 10, guardId: 'guard-a' }); + assert.equal(filtered.length, 1); + assert.equal(filtered[0].guardId, 'guard-a'); + }); + + test('queryWindow filters by threadId', async () => { + const redis = new FakeRedis(); + const log = new GuardRejectionEventLog(redis); + const ts = Date.now(); + + await log.append(makeEvent({ timestamp: ts, threadId: 'thread-x' })); + await log.append(makeEvent({ timestamp: ts + 1, threadId: 'thread-y' })); + + const filtered = await log.queryWindow({ since: ts - 1, until: ts + 10, threadId: 'thread-x' }); + assert.equal(filtered.length, 1); + assert.equal(filtered[0].threadId, 'thread-x'); + }); + + test('queryWindow filters by catId', async () => { + const redis = new FakeRedis(); + const log = new GuardRejectionEventLog(redis); + const ts = Date.now(); + + await log.append(makeEvent({ timestamp: ts, catId: 'cat-alpha' })); + await log.append(makeEvent({ timestamp: ts + 1, catId: 'cat-beta' })); + + const filtered = await log.queryWindow({ since: ts - 1, until: ts + 10, catId: 'cat-alpha' }); + assert.equal(filtered.length, 1); + assert.equal(filtered[0].catId, 'cat-alpha'); + }); + + test('countByGuard counts events for a specific guard', async () => { + const redis = new FakeRedis(); + const log = new GuardRejectionEventLog(redis); + const ts = Date.now(); + + await log.append(makeEvent({ timestamp: ts, guardId: 'guard-x' })); + await log.append(makeEvent({ timestamp: ts + 1, guardId: 'guard-x' })); + await log.append(makeBlockEvent({ timestamp: ts + 2, guardId: 'guard-y' })); + + // countByGuard uses zcount on the full ZSET, but we verify the query path works + const count = await log.countByGuard('guard-x', ts - 1, ts + 10); + // countByGuard counts ALL events in the window (ZSET doesn't filter by guardId at Redis level) + // It returns the total ZSET count in that window — in-app filtering is done by queryWindow + assert.equal(typeof count, 'number'); + assert.ok(count >= 2); // At least 2 events for guard-x exist in the window + }); + + test('append with same timestamp but different eventId stores both', async () => { + const redis = new FakeRedis(); + const log = new GuardRejectionEventLog(redis); + const ts = Date.now(); + + await log.append(makeEvent({ eventId: 'evt-aaa', timestamp: ts })); + await log.append(makeEvent({ eventId: 'evt-bbb', timestamp: ts })); + + const results = await log.queryWindow({ since: ts - 1, until: ts + 1 }); + assert.equal(results.length, 2, 'eventId ensures ZSET member uniqueness for same-ms events'); + }); + + test('route_decision_block event round-trips correctly', async () => { + const redis = new FakeRedis(); + const log = new GuardRejectionEventLog(redis); + const ts = Date.now(); + + const event = makeBlockEvent({ + timestamp: ts, + fromCatId: 'opus-47', + targetCatId: 'gpt52', + streakCount: 6, + }); + await log.append(event); + + const results = await log.queryWindow({ since: ts - 1, until: ts + 1 }); + assert.equal(results.length, 1); + assert.equal(results[0].kind, 'route_decision_block'); + assert.equal(results[0].fromCatId, 'opus-47'); + assert.equal(results[0].targetCatId, 'gpt52'); + assert.equal(results[0].streakCount, 6); + }); + + test('append is fail-open — Redis errors do not throw', async () => { + const redis = new ThrowingRedis(); + const log = new GuardRejectionEventLog(redis); + + // Should NOT throw despite Redis failure + await log.append(makeEvent()); + }); + + test('queryWindow is fail-open — returns empty on Redis error', async () => { + const redis = new ThrowingRedis(); + const log = new GuardRejectionEventLog(redis); + + const results = await log.queryWindow({ since: 0, until: Date.now() }); + assert.deepEqual(results, []); + }); + + test('countByGuard is fail-open — returns 0 on Redis error', async () => { + const redis = new ThrowingRedis(); + const log = new GuardRejectionEventLog(redis); + + const count = await log.countByGuard('guard-x', 0, Date.now()); + assert.equal(count, 0); + }); + + test('queryWindow returns events in chronological order', async () => { + const redis = new FakeRedis(); + const log = new GuardRejectionEventLog(redis); + const base = Date.now(); + + await log.append(makeEvent({ timestamp: base + 300 })); + await log.append(makeEvent({ timestamp: base + 100 })); + await log.append(makeEvent({ timestamp: base + 200 })); + + const results = await log.queryWindow({ since: base, until: base + 400 }); + assert.equal(results.length, 3); + assert.ok(results[0].timestamp <= results[1].timestamp); + assert.ok(results[1].timestamp <= results[2].timestamp); + }); + + test('P2 regression: filtered query finds target after 200 unrelated events', async () => { + // Terra's repro: 200 earlier unrelated events + 1 later target event. + // Old code applied Redis LIMIT before in-app filtering → target lost. + const redis = new FakeRedis(); + const log = new GuardRejectionEventLog(redis); + const base = Date.now(); + + // 200 events with guardId 'unrelated' + for (let i = 0; i < 200; i++) { + await log.append(makeEvent({ timestamp: base + i, guardId: 'unrelated' })); + } + // 1 target event after the 200 unrelated ones + await log.append(makeEvent({ timestamp: base + 300, guardId: 'target' })); + + const filtered = await log.queryWindow({ since: base - 1, until: base + 400, guardId: 'target' }); + assert.equal(filtered.length, 1, 'target event must survive past 200 unrelated predecessors'); + assert.equal(filtered[0].guardId, 'target'); + }); + + test('queryWindow limit applies after filtering', async () => { + const redis = new FakeRedis(); + const log = new GuardRejectionEventLog(redis); + const base = Date.now(); + + // 5 matching events + for (let i = 0; i < 5; i++) { + await log.append(makeEvent({ timestamp: base + i, guardId: 'match' })); + } + // 5 non-matching events + for (let i = 0; i < 5; i++) { + await log.append(makeEvent({ timestamp: base + 100 + i, guardId: 'other' })); + } + + const results = await log.queryWindow({ since: base - 1, until: base + 200, guardId: 'match', limit: 3 }); + assert.equal(results.length, 3, 'limit=3 should apply after guardId filter'); + assert.ok(results.every((e) => e.guardId === 'match')); + }); + + test('queryWindow until is exclusive (selector contract)', async () => { + const redis = new FakeRedis(); + const log = new GuardRejectionEventLog(redis); + const base = 1000000; + + await log.append(makeEvent({ timestamp: base })); + await log.append(makeEvent({ timestamp: base + 10 })); + await log.append(makeEvent({ timestamp: base + 20 })); + + // until=base+20 should be exclusive — event AT base+20 excluded + const results = await log.queryWindow({ since: base, until: base + 20 }); + assert.equal(results.length, 2); + assert.ok(results.every((e) => e.timestamp < base + 20)); + }); +}); diff --git a/packages/api/test/harness-eval/_guard-test-helpers.js b/packages/api/test/harness-eval/_guard-test-helpers.js new file mode 100644 index 0000000000..66283d13e4 --- /dev/null +++ b/packages/api/test/harness-eval/_guard-test-helpers.js @@ -0,0 +1,116 @@ +/** + * Canonical test helpers for guard-rejection test suites. + * + * Single source for: fake Redis (ZSET-aware + LIMIT), event factory, trigger mock. + * Used by: guard-threshold-escalation, guard-episode-coalescing, guard-rejection-r3-regression. + * + * sol R7 P1-1: extracted to prevent fake-divergence causing repeat false greens. + * + * [opus/claude-opus-4-6] + */ + +/** Base timestamp for all guard-rejection tests. */ +export const T = 1700000000000; + +/** + * Canonical fake Redis: key-value (set/get/del) + ZSET (zrangebyscore with LIMIT). + * + * This is the ONLY fake Redis for guard-rejection tests — do not create + * per-file copies (sol R7 P1-1 root cause: stale copies without ZSET break + * when production switches to pagewise reads). + * + * @param seedEvents - Events to pre-populate the ZSET with (sorted by timestamp). + */ +export function createFakeRedis(seedEvents = []) { + const store = new Map(); + const zset = seedEvents + .map((e) => ({ score: e.timestamp, member: JSON.stringify(e) })) + .sort((a, b) => a.score - b.score); + return { + get: async (key) => store.get(key) ?? null, + set: async (key, value, ...args) => { + const hasNX = args.includes('NX'); + if (hasNX && store.has(key)) return null; + store.set(key, value); + return 'OK'; + }, + del: async (key) => { + const existed = store.has(key); + store.delete(key); + return existed ? 1 : 0; + }, + expire: async () => 1, + zrangebyscore: async (_key, min, max, ...args) => { + let offset = 0; + let count = zset.length; + for (let i = 0; i < args.length; i++) { + if (String(args[i]).toUpperCase() === 'LIMIT') { + offset = Number(args[i + 1]); + count = Number(args[i + 2]); + break; + } + } + return zset + .filter((m) => m.score >= Number(min) && m.score <= Number(max)) + .slice(offset, offset + count) + .map((m) => m.member); + }, + _store: store, + _zset: zset, + }; +} + +/** + * Standard guard-rejection event factory (HttpRateLimitEvent shape). + * Override any field via `over` parameter. + */ +export function rawEvent(over = {}) { + return { + eventId: `evt-${over.timestamp ?? T}-${over.seq ?? 0}`, + kind: 'http_rate_limit', + threadId: 'thread_1', + catId: 'cat_1', + guardId: 'hold_ball_rate_limit', + ownerUserId: 'user_1', + timestamp: T, + correlationConfidence: 'window', + currentCount: 5, + maxAllowed: 5, + windowMs: 3600000, + ...over, + }; +} + +/** Lazy-loaded EventLog class for createFakeEventSource. */ +let _EventLogClass = null; + +/** + * Create a fake event source (PagewiseEventSource) backed by canonical fake Redis. + * Uses real GuardRejectionEventLog so iterateWindow() matches production. + * + * Fable ruling: every checkGuardThreshold call needs { redis, guardRejectionLog }. + * + * @param seedEvents - Events to pre-populate the ZSET with. + * @returns {{ redis, guardRejectionLog }} — pass both to checkGuardThreshold deps. + */ +export async function createFakeEventSource(seedEvents = []) { + if (!_EventLogClass) { + const mod = await import('../../dist/infrastructure/harness-eval/GuardRejectionEventLog.js'); + _EventLogClass = mod.GuardRejectionEventLog; + } + const redis = createFakeRedis(seedEvents); + return { redis, guardRejectionLog: new _EventLogClass(redis) }; +} + +/** TriggerNowSuccess mock — claim is kept only for this shape. */ +export function triggerSuccess(domainId = 'eval:harness-ledger') { + return { + ok: true, + domainId, + threadId: 't1', + messageId: 'm1', + evalCatId: 'c1', + invocationTriggered: true, + triggerOutcome: 'dispatched', + }; +} diff --git a/packages/api/test/harness-eval/eval-cat-invocation-publish-verdict.test.js b/packages/api/test/harness-eval/eval-cat-invocation-publish-verdict.test.js index e33241cb27..91fafb82b8 100644 --- a/packages/api/test/harness-eval/eval-cat-invocation-publish-verdict.test.js +++ b/packages/api/test/harness-eval/eval-cat-invocation-publish-verdict.test.js @@ -99,15 +99,18 @@ describe('Phase H AC-H4: eval cat instructions point to publish_verdict MCP tool assert.match(packet.instructions, /Use the MCP tool/, 'must redirect to MCP tool'); }); - it('instructions mention branch + commit + PR shape (so cat understands tool side-effects)', () => { + it('instructions expose artifact result shape and forbid runtime-evidence PRs', () => { const packet = buildEvalCatInvocation({ domain: { ...TEST_DOMAIN_BASE, domainId: 'eval:a2a', sourceAdapter: 'f167-runtime-eval' }, trendRefs: [], verdictRefs: [], legacyCleanup: { status: 'not_checked' }, }); - assert.match(packet.instructions, /verdict\/auto\/\{domainSlug\}\/\{verdictId\}/, 'branch name pattern'); - assert.match(packet.instructions, /commit SHA \+ PR URL/, 'response shape'); + assert.match(packet.instructions, /artifactId.*artifactUrl.*verdictPath.*bundleDir/s, 'artifact response shape'); + assert.match(packet.instructions, /outside the product Git checkout/i, 'artifact storage boundary'); + assert.match(packet.instructions, /do not.*create (?:an evidence|a verdict) PR/is, 'must forbid evidence PRs'); + assert.doesNotMatch(packet.instructions, /verdict\/auto\/\{domainSlug\}\/\{verdictId\}/, 'no branch pattern'); + assert.doesNotMatch(packet.instructions, /commit SHA \+ PR URL|self-merge|gh pr merge/i, 'no Git lifecycle'); }); it('instructions reference sourceRefs (砚砚 R1 P1 #2 + R2 P2: tool NEVER 造 evidence + basenames only)', () => { diff --git a/packages/api/test/harness-eval/eval-domain-daily.test.js b/packages/api/test/harness-eval/eval-domain-daily.test.js index eb2fbbf864..7e4a4a5654 100644 --- a/packages/api/test/harness-eval/eval-domain-daily.test.js +++ b/packages/api/test/harness-eval/eval-domain-daily.test.js @@ -1,5 +1,8 @@ import assert from 'node:assert/strict'; -import { describe, it, mock } from 'node:test'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, describe, it, mock } from 'node:test'; import { fileURLToPath } from 'node:url'; import { createEvalDomainDailySpec, @@ -244,6 +247,303 @@ describe('eval-domain-daily task spec', () => { }); }); +describe('KD-17 snapshot-first error paths (eval:harness-ledger)', () => { + // Fake domain signal matching eval:harness-ledger config. + const harnessLedgerDomain = { + domainId: 'eval:harness-ledger', + displayName: 'Harness Ledger Eval', + systemThreadId: 'thread_eval_harness_ledger', + evalCat: { catId: 'gpt52', handle: '@gpt52', model: 'gpt-5.4' }, + frequency: 'weekly', + sourceAdapter: 'f257-prompt-segments', + sourceRefsKind: 'prompt-segments', + threadPolicy: { + role: 'working-home', + stateSot: 'registry', + allowedContent: ['longitudinal-analysis', 'verdict-discussion', 'handoff-drafts'], + }, + legacyScheduledTaskIds: [], + handoffTargetResolver: { featureId: 'F257', ownerCatId: 'opus-47', threadLookup: 'feature-thread' }, + sla: { acknowledgeHours: 48, reevalWithinHours: 168 }, + enabled: true, // pretend enabled for testing execute path + }; + + it('scheduled: skips invocation when guardRejectionLog provider is absent', async () => { + // No guardRejectionLog in config → deliver SKIPPED message + return (no cat invoke). + const spec = createEvalDomainWeeklySpec({ harnessFeedbackRoot: repoHarnessFeedbackRoot }); + + const deliverMock = mock.fn(async () => 'msg_skip'); + const triggerMock = mock.fn(); + const ctx = { + assignedCatId: null, + deliver: deliverMock, + invokeTrigger: { trigger: triggerMock }, + }; + + await spec.run.execute(harnessLedgerDomain, 'eval:harness-ledger', ctx); + + // deliver was called once with SKIPPED message (not eval invocation) + assert.equal(deliverMock.mock.callCount(), 1); + const content = deliverMock.mock.calls[0].arguments[0].content; + assert.ok( + content.includes('SKIPPED (harness ledger snapshot unavailable)'), + `should contain SKIPPED header, got: ${content.slice(0, 100)}`, + ); + assert.ok( + content.includes('provider_not_wired') || content.includes('not wired'), + 'should mention provider not wired', + ); + assert.equal(deliverMock.mock.calls[0].arguments[0].threadId, 'thread_eval_harness_ledger'); + + // invokeTrigger must NOT be called (no cat invocation) + assert.equal(triggerMock.mock.callCount(), 0, 'eval cat must NOT be invoked without snapshot'); + }); + + it('scheduled: skips invocation when snapshot production throws (Redis error)', async () => { + // guardRejectionLog exists but queryWindowStrict throws → deliver SKIPPED message + return. + const throwingLog = { + queryWindowStrict: async () => { + throw new Error('READONLY: Redis failover in progress'); + }, + queryWindowStrictComplete: async () => { + throw new Error('READONLY: Redis failover in progress'); + }, + queryWindow: async () => [], + }; + + const spec = createEvalDomainWeeklySpec({ + harnessFeedbackRoot: repoHarnessFeedbackRoot, + guardRejectionLog: throwingLog, + defaultUserId: 'default-user', + }); + + const deliverMock = mock.fn(async () => 'msg_skip_err'); + const triggerMock = mock.fn(); + const ctx = { + assignedCatId: null, + deliver: deliverMock, + invokeTrigger: { trigger: triggerMock }, + }; + + await spec.run.execute(harnessLedgerDomain, 'eval:harness-ledger', ctx); + + assert.equal(deliverMock.mock.callCount(), 1); + const content = deliverMock.mock.calls[0].arguments[0].content; + assert.ok(content.includes('SKIPPED (harness ledger snapshot unavailable)'), 'should contain SKIPPED header'); + assert.ok(content.includes('Redis failover'), 'should contain error detail'); + + assert.equal(triggerMock.mock.callCount(), 0, 'eval cat must NOT be invoked on snapshot error'); + }); + + it('scheduled: delivers invocation with evidence when snapshot succeeds', async () => { + // guardRejectionLog produces data → eval cat invoked with precomputedEvidence. + // Use a temp dir so snapshot files don't pollute the repo. + const tmpRoot = mkdtempSync(join(tmpdir(), 'kd17-success-')); + const successLog = { + queryWindowStrict: async () => [ + { eventId: 'e1', kind: 'hold_ball_429', guardId: 'guard-1', timestamp: Date.now(), rawPayload: {} }, + ], + queryWindowStrictComplete: async () => ({ + events: [{ eventId: 'e1', kind: 'hold_ball_429', guardId: 'guard-1', timestamp: Date.now(), rawPayload: {} }], + truncated: false, + }), + queryWindow: async () => [], + }; + + const spec = createEvalDomainWeeklySpec({ + harnessFeedbackRoot: tmpRoot, + guardRejectionLog: successLog, + defaultUserId: 'default-user', + }); + + const deliverMock = mock.fn(async () => 'msg_success'); + const triggerMock = mock.fn(); + const ctx = { + assignedCatId: null, + deliver: deliverMock, + invokeTrigger: { trigger: triggerMock }, + }; + + await spec.run.execute(harnessLedgerDomain, 'eval:harness-ledger', ctx); + + assert.equal(deliverMock.mock.callCount(), 1); + const content = deliverMock.mock.calls[0].arguments[0].content; + // Must NOT contain SKIPPED + assert.ok(!content.includes('SKIPPED'), 'successful path should not contain SKIPPED'); + // Must contain evidence (snapshot summary) + assert.ok(content.includes('Pre-computed Guard Rejection Snapshot'), 'should contain pre-computed evidence'); + assert.ok(content.includes('evalRunId'), 'should contain evalRunId reference'); + + // KD-17 last-hop: delivered content must include exact sourceRefs JSON + // with windowStartMs, windowEndMs, and evalRunId as copyable values. + // Eval cat copies this block verbatim — no ISO→epoch conversion needed. + assert.ok(content.includes('"windowStartMs"'), 'should contain exact windowStartMs field'); + assert.ok(content.includes('"windowEndMs"'), 'should contain exact windowEndMs field'); + assert.ok(content.includes('"kind": "prompt-segments"'), 'should contain kind in sourceRefs JSON'); + + // Extract the sourceRefs JSON from the fenced code block and verify + // it would pass the generator's exact-window check against the stored snapshot. + const allJsonBlocks = [...content.matchAll(/```json\s*\n([\s\S]*?)\n\s*```/g)]; + const sourceRefsBlock = allJsonBlocks.find((m) => m[1].includes('"prompt-segments"')); + assert.ok(sourceRefsBlock, 'should have a fenced JSON block with sourceRefs'); + const sourceRefs = JSON.parse(sourceRefsBlock[1]); + assert.equal(sourceRefs.kind, 'prompt-segments'); + assert.equal(typeof sourceRefs.windowStartMs, 'number', 'windowStartMs must be a number'); + assert.equal(typeof sourceRefs.windowEndMs, 'number', 'windowEndMs must be a number'); + assert.ok(sourceRefs.windowEndMs > sourceRefs.windowStartMs, 'window must be valid'); + assert.ok(/^hlr-\d+-[a-f0-9]{8}$/.test(sourceRefs.evalRunId), 'evalRunId must match safe format'); + + // invokeTrigger must be called (cat invoked) + assert.equal(triggerMock.mock.callCount(), 1, 'eval cat must be invoked with evidence'); + + // Cleanup temp snapshot files + rmSync(tmpRoot, { recursive: true, force: true }); + }); +}); + +describe('F257 sub-item 1: zero events → skip invocation (eval:harness-ledger)', () => { + const harnessLedgerDomain = { + domainId: 'eval:harness-ledger', + displayName: 'Harness Ledger Eval', + systemThreadId: 'thread_eval_harness_ledger', + evalCat: { catId: 'gpt52', handle: '@gpt52', model: 'gpt-5.4' }, + frequency: 'weekly', + sourceAdapter: 'f257-prompt-segments', + sourceRefsKind: 'prompt-segments', + threadPolicy: { + role: 'working-home', + stateSot: 'registry', + allowedContent: ['longitudinal-analysis', 'verdict-discussion', 'handoff-drafts'], + }, + legacyScheduledTaskIds: [], + handoffTargetResolver: { featureId: 'F257', ownerCatId: 'opus-47', threadLookup: 'feature-thread' }, + sla: { acknowledgeHours: 48, reevalWithinHours: 168 }, + enabled: true, + }; + + it('scheduled: skips invocation when snapshot has zero events (LLM cost = 0)', async () => { + // guardRejectionLog returns empty array → totalEvents = 0 → skip. + const tmpRoot = mkdtempSync(join(tmpdir(), 'kd17-zero-')); + const emptyLog = { + queryWindowStrict: async () => [], + queryWindowStrictComplete: async () => ({ events: [], truncated: false }), + queryWindow: async () => [], + }; + + const spec = createEvalDomainWeeklySpec({ + harnessFeedbackRoot: tmpRoot, + guardRejectionLog: emptyLog, + defaultUserId: 'default-user', + }); + + const deliverMock = mock.fn(async () => 'msg_zero'); + const triggerMock = mock.fn(); + const ctx = { + assignedCatId: null, + deliver: deliverMock, + invokeTrigger: { trigger: triggerMock }, + }; + + await spec.run.execute(harnessLedgerDomain, 'eval:harness-ledger', ctx); + + // deliver was called once with SKIPPED (zero events) message + assert.equal(deliverMock.mock.callCount(), 1); + const content = deliverMock.mock.calls[0].arguments[0].content; + assert.ok( + content.includes('SKIPPED (zero events in window)'), + `should contain zero-events SKIPPED header, got: ${content.slice(0, 120)}`, + ); + assert.ok(content.includes('evalRunId'), 'should mention evalRunId for audit trail'); + assert.ok(content.includes('LLM cost = 0'), 'should mention cost savings'); + assert.equal(deliverMock.mock.calls[0].arguments[0].threadId, 'thread_eval_harness_ledger'); + + // invokeTrigger must NOT be called (no cat invocation — nothing to evaluate) + assert.equal(triggerMock.mock.callCount(), 0, 'eval cat must NOT be invoked on zero events'); + + rmSync(tmpRoot, { recursive: true, force: true }); + }); + + it('scheduled: zero-event skip still writes snapshot file (audit trail)', async () => { + const tmpRoot = mkdtempSync(join(tmpdir(), 'kd17-zero-snap-')); + const emptyLog = { + queryWindowStrict: async () => [], + queryWindowStrictComplete: async () => ({ events: [], truncated: false }), + queryWindow: async () => [], + }; + + const spec = createEvalDomainWeeklySpec({ + harnessFeedbackRoot: tmpRoot, + guardRejectionLog: emptyLog, + defaultUserId: 'default-user', + }); + + const deliverMock = mock.fn(async () => 'msg_zero_snap'); + const ctx = { + assignedCatId: null, + deliver: deliverMock, + invokeTrigger: { trigger: mock.fn() }, + }; + + await spec.run.execute(harnessLedgerDomain, 'eval:harness-ledger', ctx); + + // Snapshot should still exist on disk (audit trail even for empty windows) + const { readdirSync, readFileSync } = await import('node:fs'); + const snapshotsDir = join(tmpRoot, 'run-snapshots'); + const files = readdirSync(snapshotsDir); + assert.equal(files.length, 1, 'exactly one snapshot file should exist'); + const snapshot = JSON.parse(readFileSync(join(snapshotsDir, files[0]), 'utf8')); + assert.equal(snapshot.totalEvents, 0, 'snapshot should record zero events'); + assert.ok(/^hlr-\d+-[a-f0-9]{8}$/.test(snapshot.evalRunId), 'evalRunId format'); + + rmSync(tmpRoot, { recursive: true, force: true }); + }); + + it('scheduled: skips invocation when defaultUserId is missing (owner_scope_missing)', async () => { + // guardRejectionLog exists BUT defaultUserId is missing → deliver SKIPPED + return. + // sol R9 P1-2: fail-closed, never substitute synthetic placeholder. + const logMock = { + queryWindowStrict: mock.fn(async () => []), + queryWindowStrictComplete: mock.fn(async () => ({ events: [], truncated: false })), + queryWindow: mock.fn(async () => []), + }; + + // Config has guardRejectionLog but NO defaultUserId + const spec = createEvalDomainWeeklySpec({ + harnessFeedbackRoot: repoHarnessFeedbackRoot, + guardRejectionLog: logMock, + // defaultUserId deliberately omitted + }); + + const deliverMock = mock.fn(async () => 'msg_no_owner'); + const triggerMock = mock.fn(); + const ctx = { + assignedCatId: null, + deliver: deliverMock, + invokeTrigger: { trigger: triggerMock }, + }; + + await spec.run.execute(harnessLedgerDomain, 'eval:harness-ledger', ctx); + + // deliver was called once with SKIPPED message + assert.equal(deliverMock.mock.callCount(), 1, 'should deliver skip message'); + const content = deliverMock.mock.calls[0].arguments[0].content; + assert.ok( + content.includes('SKIPPED (harness ledger snapshot unavailable)'), + `should contain SKIPPED header, got: ${content.slice(0, 120)}`, + ); + assert.ok( + content.includes('defaultUserId') || content.includes('owner scope'), + 'should mention missing owner scope', + ); + + // invokeTrigger must NOT be called + assert.equal(triggerMock.mock.callCount(), 0, 'eval cat must NOT be invoked without owner scope'); + + // guardRejectionLog must NOT be queried (fail-closed before any data access) + assert.equal(logMock.queryWindowStrictComplete.mock.callCount(), 0, 'must NOT query events without owner scope'); + }); +}); + describe('eval-domain-weekly task spec (AC-E19, AC-E20)', () => { it('returns a valid TaskSpec_P1 with weekly cron and correct id', () => { const spec = createEvalDomainWeeklySpec({ harnessFeedbackRoot: repoHarnessFeedbackRoot }); @@ -260,7 +560,7 @@ describe('eval-domain-weekly task spec (AC-E19, AC-E20)', () => { assert.equal(spec.display.category, 'system'); }); - it('weekly gate includes enabled weekly domains (capability-wakeup + sop), excludes daily', async () => { + it('weekly gate includes enabled weekly domains (capability-wakeup + sop + harness-ledger), excludes daily', async () => { const spec = createEvalDomainWeeklySpec({ harnessFeedbackRoot: repoHarnessFeedbackRoot }); const result = await spec.admission.gate(); @@ -273,6 +573,11 @@ describe('eval-domain-weekly task spec (AC-E19, AC-E20)', () => { ); // Re-enabled 2026-06-10 by feat/f192-sop-wiring: all 3 wiring conditions met. assert.ok(domainIds.includes('eval:sop'), 'eval:sop (re-enabled) must appear in weekly gate'); + // KD-17 snapshot-first: eval:harness-ledger re-enabled after data access resolved. + assert.ok( + domainIds.includes('eval:harness-ledger'), + 'eval:harness-ledger (weekly + re-enabled after KD-17) must appear in weekly gate', + ); assert.ok(!domainIds.includes('eval:a2a'), 'eval:a2a (daily) must NOT appear in weekly gate'); assert.ok(!domainIds.includes('eval:memory'), 'eval:memory (daily) must NOT appear in weekly gate'); assert.ok(!domainIds.includes('eval:task-outcome'), 'eval:task-outcome (daily) must NOT appear in weekly gate'); diff --git a/packages/api/test/harness-eval/eval-domain-evidence-gate.test.js b/packages/api/test/harness-eval/eval-domain-evidence-gate.test.js new file mode 100644 index 0000000000..069a965707 --- /dev/null +++ b/packages/api/test/harness-eval/eval-domain-evidence-gate.test.js @@ -0,0 +1,234 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { describe, it, mock } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { createEvalDomainDailySpec } from '../../dist/infrastructure/harness-eval/domain/eval-domain-daily.js'; +import { + createTelemetryEvidencePrereqProbe, + evaluateEvidencePrereq, +} from '../../dist/infrastructure/harness-eval/domain/eval-domain-evidence-gate.js'; +import { createEvalDomainNDaySpec } from '../../dist/infrastructure/harness-eval/domain/eval-domain-nday.js'; + +const repoHarnessFeedbackRoot = fileURLToPath(new URL('../../../../docs/harness-feedback', import.meta.url)); + +/** + * Evidence-source prereq gate (eval:a2a build verdict + * `2026-07-07-eval-a2a-reeval-telemetry-still-disabled-build`, PR #19). + * + * Bug class: the scheduled eval fires on a runtime whose OTel telemetry is + * disabled (TELEMETRY_HMAC_SALT unset → initTelemetry() returned null handles). + * The `f167-runtime-eval` source cannot produce fresh snapshots, yet the eval + * cat is invoked anyway and burns a full LLM session to re-conclude "telemetry + * still disabled" — every day (2026-06-30 → 2026-07-07 verdict series). The + * gate fails closed BEFORE invocation and posts a zero-LLM-cost skip notice + * to the domain's own system thread instead. + */ +describe('eval-domain evidence-source prereq gate (eval:a2a PR #19)', () => { + const mkCtx = () => { + const deliverMock = mock.fn(async () => 'msg_evidence'); + const triggerMock = mock.fn(); + return { + deliverMock, + triggerMock, + ctx: { assignedCatId: null, deliver: deliverMock, invokeTrigger: { trigger: triggerMock } }, + }; + }; + + describe('createTelemetryEvidencePrereqProbe', () => { + it('telemetry-backed adapter + OTel disabled → not ok, reason points at salt', () => { + const probe = createTelemetryEvidencePrereqProbe({ otelEnabled: () => false }); + const result = probe({ domainId: 'eval:a2a', sourceAdapter: 'f167-runtime-eval' }); + assert.equal(result.ok, false); + assert.ok( + result.reason.includes('TELEMETRY_HMAC_SALT'), + `default reason must name the missing salt env var (got: ${result.reason})`, + ); + }); + + it('telemetry-backed adapter + OTel enabled → ok', () => { + const probe = createTelemetryEvidencePrereqProbe({ otelEnabled: () => true }); + const result = probe({ domainId: 'eval:a2a', sourceAdapter: 'f167-runtime-eval' }); + assert.equal(result.ok, true); + }); + + it('non-telemetry adapter passes through even when OTel is disabled', () => { + const probe = createTelemetryEvidencePrereqProbe({ otelEnabled: () => false }); + const result = probe({ domainId: 'eval:sop', sourceAdapter: 'sop-trace-eval' }); + assert.equal(result.ok, true, 'gate must only constrain telemetry-backed source adapters'); + }); + + it('OTEL_SDK_DISABLED=true → reason names the env toggle, not the salt', () => { + const prev = process.env.OTEL_SDK_DISABLED; + process.env.OTEL_SDK_DISABLED = 'true'; + try { + const probe = createTelemetryEvidencePrereqProbe({ otelEnabled: () => false }); + const result = probe({ domainId: 'eval:a2a', sourceAdapter: 'f167-runtime-eval' }); + assert.equal(result.ok, false); + assert.ok(result.reason.includes('OTEL_SDK_DISABLED'), `got: ${result.reason}`); + } finally { + if (prev === undefined) delete process.env.OTEL_SDK_DISABLED; + else process.env.OTEL_SDK_DISABLED = prev; + } + }); + }); + + it('bootstrap wires the telemetry init state into every scheduled eval spec', () => { + const indexSource = readFileSync(new URL('../../src/index.ts', import.meta.url), 'utf8'); + + assert.match(indexSource, /createTelemetryEvidencePrereqProbe/); + assert.match( + indexSource, + /otelEnabled:\s*\(\)\s*=>\s*telemetryHandle\.getMetricsText\s*!==\s*null/, + 'the probe must observe the actual boot-time telemetry handle, not an env proxy', + ); + assert.match( + indexSource, + /const evalScheduleOpts = \{[\s\S]*?evidencePrereqProbe,[\s\S]*?\};/, + 'shared daily, weekly, and N-day schedule options must include the evidence probe', + ); + }); + + describe('evaluateEvidencePrereq', () => { + it('probe throw → fail-closed not-ok with reason', async () => { + const result = await evaluateEvidencePrereq( + () => { + throw new Error('synthetic evidence probe failure'); + }, + { domainId: 'eval:a2a', sourceAdapter: 'f167-runtime-eval' }, + ); + assert.equal(result.ok, false); + assert.ok(result.reason.includes('synthetic evidence probe failure')); + }); + }); + + describe('daily spec integration', () => { + async function getA2aItem(spec) { + const gateResult = await spec.admission.gate(); + const item = gateResult.workItems.find((w) => w.subjectKey === 'eval:a2a'); + assert.ok(item, 'eval:a2a must be a registered daily domain'); + return item; + } + + it('probe not-ok → SKIPPED notice in domain thread, cat never invoked', async () => { + const spec = createEvalDomainDailySpec({ + harnessFeedbackRoot: repoHarnessFeedbackRoot, + defaultUserId: 'default-user', + evidencePrereqProbe: () => ({ ok: false, reason: 'OTel disabled at boot: HMAC salt validation failed' }), + }); + const item = await getA2aItem(spec); + const { deliverMock, triggerMock, ctx } = mkCtx(); + + await spec.run.execute(item.signal, item.subjectKey, ctx); + + assert.equal(triggerMock.mock.callCount(), 0, 'trigger must NOT fire when evidence source is down'); + assert.equal(deliverMock.mock.callCount(), 1); + const call = deliverMock.mock.calls[0].arguments[0]; + assert.equal(call.threadId, 'thread_eval_a2a', 'skip notice must stay in the domain system thread'); + assert.equal(call.userId, 'scheduler'); + assert.ok(call.content.includes('SKIPPED (evidence source unavailable)'), 'stable header for grep/dedup'); + assert.ok(call.content.includes('HMAC salt validation failed'), 'notice must carry the probe reason'); + assert.ok(call.content.includes('TELEMETRY_HMAC_SALT'), 'notice must state the actionable next step'); + }); + + it('probe ok → normal invocation proceeds', async () => { + const spec = createEvalDomainDailySpec({ + harnessFeedbackRoot: repoHarnessFeedbackRoot, + defaultUserId: 'default-user', + evidencePrereqProbe: () => ({ ok: true }), + }); + const item = await getA2aItem(spec); + const { deliverMock, triggerMock, ctx } = mkCtx(); + + await spec.run.execute(item.signal, item.subjectKey, ctx); + + assert.equal(triggerMock.mock.callCount(), 1, 'trigger fires when evidence source is healthy'); + assert.equal(deliverMock.mock.callCount(), 1); + assert.ok(!deliverMock.mock.calls[0].arguments[0].content.includes('SKIPPED')); + }); + + it('probe throws → fail-closed skip, no crash, no LLM call', async () => { + const spec = createEvalDomainDailySpec({ + harnessFeedbackRoot: repoHarnessFeedbackRoot, + defaultUserId: 'default-user', + evidencePrereqProbe: () => { + throw new Error('synthetic gate crash'); + }, + }); + const item = await getA2aItem(spec); + const { deliverMock, triggerMock, ctx } = mkCtx(); + + await spec.run.execute(item.signal, item.subjectKey, ctx); + + assert.equal(triggerMock.mock.callCount(), 0); + assert.equal(deliverMock.mock.callCount(), 1); + assert.ok(deliverMock.mock.calls[0].arguments[0].content.includes('SKIPPED (evidence source unavailable)')); + }); + + it('both gates failing → evidence-source message wins (upstream-first ordering)', async () => { + const spec = createEvalDomainDailySpec({ + harnessFeedbackRoot: repoHarnessFeedbackRoot, + defaultUserId: 'default-user', + evidencePrereqProbe: () => ({ ok: false, reason: 'OTel disabled at boot' }), + publishPrereqProbe: () => false, + }); + const item = await getA2aItem(spec); + const { deliverMock, triggerMock, ctx } = mkCtx(); + + await spec.run.execute(item.signal, item.subjectKey, ctx); + + assert.equal(triggerMock.mock.callCount(), 0); + assert.equal(deliverMock.mock.callCount(), 1, 'exactly one skip notice, not two'); + const content = deliverMock.mock.calls[0].arguments[0].content; + assert.ok(content.includes('evidence source unavailable'), 'evidence gate runs before publish gate'); + assert.ok(!content.includes('publish prereq missing')); + }); + + it('evidence probe ok + publish gate still enforced → publish skip preserved', async () => { + const spec = createEvalDomainDailySpec({ + harnessFeedbackRoot: repoHarnessFeedbackRoot, + defaultUserId: 'default-user', + evidencePrereqProbe: () => ({ ok: true }), + publishPrereqProbe: () => false, + }); + const item = await getA2aItem(spec); + const { deliverMock, triggerMock, ctx } = mkCtx(); + + await spec.run.execute(item.signal, item.subjectKey, ctx); + + assert.equal(triggerMock.mock.callCount(), 0); + assert.equal(deliverMock.mock.callCount(), 1); + assert.ok(deliverMock.mock.calls[0].arguments[0].content.includes('publish prereq missing')); + }); + }); + + describe('nday spec integration', () => { + it('probe not-ok → skip notice, no trigger, no Redis last-dispatch write', async () => { + const redisSet = mock.fn(async () => 'OK'); + const redis = { get: mock.fn(async () => null), set: redisSet }; + const spec = createEvalDomainNDaySpec({ + harnessFeedbackRoot: repoHarnessFeedbackRoot, + defaultUserId: 'default-user', + redis, + evidencePrereqProbe: () => ({ ok: false, reason: 'OTel disabled at boot' }), + }); + + const gateResult = await spec.admission.gate(); + assert.equal(gateResult.run, true, 'registry must contain at least one N-day domain'); + const item = gateResult.workItems[0]; + const { deliverMock, triggerMock, ctx } = mkCtx(); + + await spec.run.execute(item.signal, item.subjectKey, ctx); + + assert.equal(triggerMock.mock.callCount(), 0, 'trigger must NOT fire when evidence source is down'); + assert.equal(deliverMock.mock.callCount(), 1); + const call = deliverMock.mock.calls[0].arguments[0]; + assert.equal(call.threadId, item.signal.systemThreadId); + assert.ok(call.content.includes('SKIPPED (evidence source unavailable)')); + assert.equal( + redisSet.mock.callCount(), + 0, + 'skip must NOT consume the N-day window — domain retries on next daily probe', + ); + }); + }); +}); diff --git a/packages/api/test/harness-eval/eval-hub-read-model.test.js b/packages/api/test/harness-eval/eval-hub-read-model.test.js index d738271e08..1d5ed92c81 100644 --- a/packages/api/test/harness-eval/eval-hub-read-model.test.js +++ b/packages/api/test/harness-eval/eval-hub-read-model.test.js @@ -233,10 +233,10 @@ Evidence: assert.ok(summary.domains, 'domains field must exist'); assert.equal( summary.domains.length, - 9, - 'should have 9 registered domains (eval:a2a + eval:memory + eval:sop + eval:capability-wakeup + eval:task-outcome + eval:friction[F245] + eval:anchor-first[F236] + eval:capability-tips[F244] + eval:qc[F253])', + 10, + 'should have 10 registered domains (eval:a2a + eval:memory + eval:sop + eval:capability-wakeup + eval:task-outcome + eval:friction[F245] + eval:anchor-first[F236] + eval:capability-tips[F244] + eval:qc[F253] + eval:harness-ledger[F257])', ); - assert.equal(summary.counts.registeredDomains, 9); + assert.equal(summary.counts.registeredDomains, 10); // F245 Phase C: eval:friction registered + enabled:true since PR1b wired the live sink. const frictionDomain = summary.domains.find((d) => d.domainId === 'eval:friction'); assert.ok(frictionDomain, 'eval:friction must appear in Hub domains'); @@ -250,9 +250,8 @@ Evidence: const memoryDomain = summary.domains.find((d) => d.domainId === 'eval:memory'); assert.ok(memoryDomain, 'eval:memory must appear in domains'); - // Updated 2026-06-10: PR #2187 merged the first eval:memory live verdict. - assert.equal(memoryDomain.hasVerdict, true); - assert.ok(memoryDomain.latestVerdictId, 'eval:memory should have latestVerdictId'); + // eval:memory is registered but has no merged live verdict yet. + assert.equal(memoryDomain.hasVerdict, false); assert.equal(memoryDomain.evalCatHandle, '@opus47'); const sopDomain = summary.domains.find((d) => d.domainId === 'eval:sop'); @@ -262,9 +261,8 @@ Evidence: const capabilityWakeupDomain = summary.domains.find((d) => d.domainId === 'eval:capability-wakeup'); assert.ok(capabilityWakeupDomain, 'eval:capability-wakeup must appear in domains'); - // Updated 2026-06-06: PR #2129 merged cap-wakeup-c1-baseline-probe verdict to main - assert.equal(capabilityWakeupDomain.hasVerdict, true); - assert.ok(capabilityWakeupDomain.latestVerdictId, 'eval:capability-wakeup should have latestVerdictId'); + // eval:capability-wakeup is registered but has no merged live verdict yet. + assert.equal(capabilityWakeupDomain.hasVerdict, false); assert.equal(capabilityWakeupDomain.evalCatHandle, '@opus47'); // F253 Phase C: eval:qc domain (zero-baseline, weekly, opus) @@ -393,6 +391,157 @@ Evidence: ); }); + it('artifact-store verdicts take precedence over legacy in-repo verdicts with the same id', () => { + const harnessFeedbackRoot = mkdtempSync(join(tmpdir(), 'f257-eval-hub-precedence-')); + const artifactStoreRoot = join(harnessFeedbackRoot, 'data', 'harness-feedback', 'artifacts'); + const verdictsDir = join(harnessFeedbackRoot, 'verdicts'); + const domainsDir = join(harnessFeedbackRoot, 'eval-domains'); + mkdirSync(verdictsDir, { recursive: true }); + mkdirSync(domainsDir, { recursive: true }); + + writeFileSync( + join(domainsDir, 'eval-a2a.yaml'), + readFileSync(join(repoHarnessFeedbackRoot, 'eval-domains', 'eval-a2a.yaml'), 'utf8'), + ); + + const sharedId = '2026-05-24-eval-a2a-shared-id'; + + // Legacy in-repo verdict with "legacy" ownerAsk. + const legacyVerdictPath = join(verdictsDir, `${sharedId}.md`); + writeFileSync( + legacyVerdictPath, + `--- +feature_ids: [F192] +topics: [harness-eval] +doc_kind: harness-feedback +feedback_type: live-verdict +domain_id: eval:a2a +packet_id: vhp_legacy +--- + +# Live Verdict - ${sharedId} + +- Verdict: \`keep_observe\` +- Phenomenon: legacy +- Harness: F167/C1 (hold_ball (MCP tool)) +- Owner ask: legacy +- Re-eval: 2099-01-01T00:00:00.000Z + +Evidence: +- snapshot:bundle/${sharedId}/snapshot +`, + ); + const legacyBundleDir = join(harnessFeedbackRoot, 'bundles', sharedId); + mkdirSync(legacyBundleDir, { recursive: true }); + writeJson(join(legacyBundleDir, 'snapshot.json'), { + verdictId: sharedId, + evalSnapshotId: 'eval-legacy', + featureId: 'F167', + generatedAt: '2099-01-01T00:00:00.000Z', + window: { startMs: 1, endMs: 2, durationHours: 0 }, + components: [ + { + componentId: 'C1', + componentName: 'test component', + confidence: 'medium', + activationCounts: { 'test.metric': 1 }, + frictionCounts: {}, + }, + ], + }); + writeJson(join(legacyBundleDir, 'attribution.json'), { + verdictId: sharedId, + featureId: 'F167', + evalSnapshotId: 'eval-legacy', + generatedAt: '2099-01-01T00:00:00.000Z', + findings: [], + noFindingRecord: { reason: 'legacy no finding', evidence: 'legacy-evidence' }, + }); + writeJson(join(legacyBundleDir, 'provenance.json'), { + verdictId: sharedId, + generatedAt: '2099-01-01T00:00:00.000Z', + rawInputs: [{ path: 'legacy-input', sha256: '0'.repeat(64) }], + generator: { name: 'test', version: '1.0.0' }, + sanitizeRulesVersion: '1.0.0', + }); + + // Artifact-store verdict with the same id but "artifact" ownerAsk. + const artifactDomainDir = join(artifactStoreRoot, 'eval-a2a', sharedId); + const artifactVerdictDir = join(artifactDomainDir, 'docs', 'harness-feedback', 'verdicts'); + const artifactBundleDir = join(artifactDomainDir, 'docs', 'harness-feedback', 'bundles', sharedId); + mkdirSync(artifactVerdictDir, { recursive: true }); + mkdirSync(artifactBundleDir, { recursive: true }); + writeFileSync( + join(artifactVerdictDir, `${sharedId}.md`), + `--- +feature_ids: [F192] +topics: [harness-eval] +doc_kind: harness-feedback +feedback_type: live-verdict +domain_id: eval:a2a +packet_id: vhp_artifact +--- + +# Live Verdict - ${sharedId} + +- Verdict: \`keep_observe\` +- Phenomenon: artifact +- Harness: F167/C1 (hold_ball (MCP tool)) +- Owner ask: artifact +- Re-eval: 2099-01-01T00:00:00.000Z + +Evidence: +- snapshot:bundle/${sharedId}/snapshot +`, + ); + writeJson(join(artifactBundleDir, 'snapshot.json'), { + verdictId: sharedId, + evalSnapshotId: 'eval-artifact', + featureId: 'F167', + generatedAt: '2099-01-01T00:00:00.000Z', + window: { startMs: 1, endMs: 2, durationHours: 0 }, + components: [ + { + componentId: 'C1', + componentName: 'test component', + confidence: 'medium', + activationCounts: { 'test.metric': 1 }, + frictionCounts: {}, + }, + ], + }); + writeJson(join(artifactBundleDir, 'attribution.json'), { + verdictId: sharedId, + featureId: 'F167', + evalSnapshotId: 'eval-artifact', + generatedAt: '2099-01-01T00:00:00.000Z', + findings: [], + noFindingRecord: { reason: 'artifact no finding', evidence: 'artifact-evidence' }, + }); + writeJson(join(artifactBundleDir, 'provenance.json'), { + verdictId: sharedId, + generatedAt: '2099-01-01T00:00:00.000Z', + rawInputs: [{ path: 'artifact-input', sha256: '0'.repeat(64) }], + generator: { name: 'test', version: '1.0.0' }, + sanitizeRulesVersion: '1.0.0', + }); + + const summary = loadEvalHubSummary({ + harnessFeedbackRoot, + artifactStoreRoot, + now: new Date('2099-01-01T00:00:00.000Z'), + }); + + const item = summary.items.find((v) => v.id === sharedId); + assert.ok(item, 'shared-id verdict must appear exactly once'); + assert.match(item.phenomenon, /artifact/, 'artifact-store verdict must take precedence over legacy'); + assert.match( + item.source.verdictPath, + /data\/harness-feedback\/artifacts\/eval-a2a/, + 'source path must point to artifact store, not legacy in-repo docs', + ); + }); + // PR-3 R1 (砚砚 P1): lifecycle.stale tests + writeA2aLiveVerdict / setupA2aOnlyHarnessFeedbackRoot // helpers extracted to `eval-hub-read-model-lifecycle.test.js` (AGENTS.md 350-line limit). }); diff --git a/packages/api/test/harness-eval/eval-hub-route-newline.test.js b/packages/api/test/harness-eval/eval-hub-route-newline.test.js index 4e307b2793..9dff6506a0 100644 --- a/packages/api/test/harness-eval/eval-hub-route-newline.test.js +++ b/packages/api/test/harness-eval/eval-hub-route-newline.test.js @@ -5,6 +5,7 @@ import { describe, it } from 'node:test'; import { fileURLToPath } from 'node:url'; import Fastify from 'fastify'; import { evalHubRoutes } from '../../dist/routes/eval-hub.js'; +import { createMockArtifactPublisher } from './publish-verdict-fixtures.js'; /** * F192 Phase H — newline-injection lock for publish-verdict route. @@ -42,20 +43,17 @@ function buildAgentKeyPublishApp() { return { ok: false, reason: 'unknown_invocation' }; }, }; - const mockGitPublisher = { - async publishOnIsolatedWorktree(opts) { - const wt = mkdtempSync(`${tmpdir()}/phase-h-newline-route-`); - await opts.stage(wt); - return { commitSha: 'mock-sha', prUrl: 'https://example.com/pr/1' }; - }, - }; + const artifactPublisher = createMockArtifactPublisher({ + artifactId: 'mock-sha', + artifactUrl: 'artifact://eval-a2a/mock-artifact', + }); const mockGenerator = async (packet, _sources, deps) => ({ verdictPath: `${deps.harnessFeedbackRoot}/verdicts/${packet.id}.md`, bundleDir: `${deps.harnessFeedbackRoot}/bundles/${packet.id}`, }); app.register(evalHubRoutes, { harnessFeedbackRoot: repoHarnessFeedbackRoot, - gitPublisher: mockGitPublisher, + artifactPublisher, verdictGenerators: { 'eval:a2a': mockGenerator }, callbackRegistry, agentKeyRegistry, diff --git a/packages/api/test/harness-eval/eval-hub-route.test.js b/packages/api/test/harness-eval/eval-hub-route.test.js index 7b7bb98682..bd4d09f99c 100644 --- a/packages/api/test/harness-eval/eval-hub-route.test.js +++ b/packages/api/test/harness-eval/eval-hub-route.test.js @@ -6,6 +6,7 @@ import { describe, it } from 'node:test'; import { fileURLToPath } from 'node:url'; import Fastify from 'fastify'; import { evalHubRoutes } from '../../dist/routes/eval-hub.js'; +import { createMockArtifactPublisher } from './publish-verdict-fixtures.js'; /** * 砚砚 R17 P1: snapshots/attributions are gitignored, raw evidence lives in LIVE @@ -109,14 +110,10 @@ describe('Eval Hub API route', () => { return { ok: false, reason: 'unknown_invocation' }; }, }; - const mockGitPublisher = { - async publishOnIsolatedWorktree(opts) { - // 砚砚 R17 P1: empty isolated worktree; stage copies LIVE evidence in. - const wt = mkdtempSync(`${tmpdir()}/phase-h-r10-route-`); - await opts.stage(wt); - return { commitSha: 'mock-sha', prUrl: 'https://example.com/pr/1' }; - }, - }; + const artifactPublisher = createMockArtifactPublisher({ + artifactId: 'mock-artifact', + artifactUrl: 'artifact://eval-a2a/mock-artifact', + }); const mockGenerator = async (packet, sources, deps) => { if (generatorSpy) generatorSpy(packet, sources, deps); return { @@ -126,7 +123,7 @@ describe('Eval Hub API route', () => { }; app.register(evalHubRoutes, { harnessFeedbackRoot: liveHarnessRoot ?? repoHarnessFeedbackRoot, - gitPublisher: mockGitPublisher, + artifactPublisher, verdictGenerators: { 'eval:a2a': mockGenerator }, callbackRegistry, ...(withAgentKeyRegistry ? { agentKeyRegistry } : {}), @@ -183,8 +180,10 @@ describe('Eval Hub API route', () => { // Mock publisher returns success → 200. assert.equal(response.statusCode, 200, `expected 200, got ${response.statusCode}: ${response.body}`); const body = response.json(); - assert.equal(body.commitSha, 'mock-sha'); - assert.equal(body.prUrl, 'https://example.com/pr/1'); + assert.equal(body.artifactId, 'mock-artifact'); + assert.equal(body.artifactUrl, 'artifact://eval-a2a/mock-artifact'); + assert.equal('commitSha' in body, false); + assert.equal('prUrl' in body, false); await app.close(); }); @@ -265,12 +264,7 @@ describe('Eval Hub API route', () => { }; app.register(evalHubRoutes, { harnessFeedbackRoot: repoHarnessFeedbackRoot, - gitPublisher: { - async publishOnIsolatedWorktree(opts) { - await opts.stage('/tmp/wrong-cat-test'); - return { commitSha: 'x', prUrl: 'x' }; - }, - }, + artifactPublisher: createMockArtifactPublisher({ artifactId: 'x', artifactUrl: 'x' }), verdictGenerators: { 'eval:a2a': async () => ({ verdictPath: '/x', bundleDir: '/x' }) }, callbackRegistry: { async verify() { @@ -296,5 +290,83 @@ describe('Eval Hub API route', () => { assert.match(body.detail, /opus-47/); await app.close(); }); + + it('sol R2 P2-5: publish 403 emits publish_policy_reject octet; non-403 errors do NOT emit', async () => { + const appended = []; + const guardRejectionLog = { + async append(event) { + appended.push(event); + }, + }; + const agentKeyRegistry = { + async verify() { + return { + ok: true, + record: { + agentKeyId: 'ak-test-003', + catId: 'opus-47', + userId: 'you', + secretHash: 'u', + salt: 'u', + scope: 'user-bound', + issuedAt: Date.now() - 1000, + expiresAt: Date.now() + 3_600_000, + }, + }; + }, + }; + const app = Fastify({ logger: false }); + app.register(evalHubRoutes, { + harnessFeedbackRoot: repoHarnessFeedbackRoot, + artifactPublisher: createMockArtifactPublisher({ artifactId: 'x', artifactUrl: 'x' }), + verdictGenerators: { 'eval:a2a': async () => ({ verdictPath: '/x', bundleDir: '/x' }) }, + callbackRegistry: { + async verify() { + return { ok: false, reason: 'unknown_invocation' }; + }, + }, + agentKeyRegistry, + guardRejectionLog, + }); + + // 403 path (wrong cat for domain) → one publish_policy_reject event. + const forbidden = await app.inject({ + method: 'POST', + url: '/api/eval-domains/eval:a2a/publish-verdict', + headers: { 'x-agent-key-secret': 'agent-key-test-secret', 'content-type': 'application/json' }, + payload: JSON.stringify({ + packet: validPacket, + sourceRefs: { snapshotName: 'snap.yaml', attributionName: 'attr.yaml' }, + }), + }); + assert.equal(forbidden.statusCode, 403); + assert.equal(forbidden.json().ledgerId, 'eval/publish-verdict-authority', 'rejection carries pot coordinate'); + assert.equal(appended.length, 1, 'domain-authority 403 must emit exactly one event'); + const event = appended[0]; + assert.equal(event.kind, 'publish_policy_reject'); + assert.equal(event.guardId, 'publish_verdict_authority'); + assert.equal(event.ledgerId, 'eval/publish-verdict-authority'); + assert.equal(event.catId, 'opus-47'); + assert.equal(event.ownerUserId, 'you', 'owner scope server-injected'); + assert.equal(event.threadId, 'unknown', 'agent_key principal has no thread binding'); + assert.equal(event.invocationId, 'unknown'); + assert.equal(event.correlationConfidence, 'window'); + assert.equal(event.sourceTool, 'publish_verdict'); + assert.equal(event.layer, 'api-route'); + + // Counter-example: unsupported domain → 501, NOT a pot firing. + const unsupported = await app.inject({ + method: 'POST', + url: '/api/eval-domains/eval:no-such-domain/publish-verdict', + headers: { 'x-agent-key-secret': 'agent-key-test-secret', 'content-type': 'application/json' }, + payload: JSON.stringify({ + packet: validPacket, + sourceRefs: { snapshotName: 'snap.yaml', attributionName: 'attr.yaml' }, + }), + }); + assert.notEqual(unsupported.statusCode, 403, 'unsupported domain is not an authority rejection'); + assert.equal(appended.length, 1, 'non-403 handler errors must NOT emit (auth-shape/infra are not pots)'); + await app.close(); + }); }); }); diff --git a/packages/api/test/harness-eval/eval-manual-trigger-fixtures.js b/packages/api/test/harness-eval/eval-manual-trigger-fixtures.js index 5ca5a5c6ce..2af8bdbe3e 100644 --- a/packages/api/test/harness-eval/eval-manual-trigger-fixtures.js +++ b/packages/api/test/harness-eval/eval-manual-trigger-fixtures.js @@ -131,6 +131,21 @@ fixtures: [] }), ); + write( + 'eval-harness-ledger.yaml', + yamlFor({ + domainId: 'eval:harness-ledger', + displayName: 'Harness Ledger Eval', + threadId: 'thread_eval_harness_ledger', + catId: 'gpt52', + model: 'gpt-5.4', + frequency: 'weekly', + sourceAdapter: 'f257-prompt-segments', + sourceRefsKind: 'prompt-segments', + featureId: 'F257', + }), + ); + return root; } diff --git a/packages/api/test/harness-eval/eval-manual-trigger-handlers.test.js b/packages/api/test/harness-eval/eval-manual-trigger-handlers.test.js index e626d4f624..ef5757e4d9 100644 --- a/packages/api/test/harness-eval/eval-manual-trigger-handlers.test.js +++ b/packages/api/test/harness-eval/eval-manual-trigger-handlers.test.js @@ -3,7 +3,7 @@ import { readFileSync, rmSync } from 'node:fs'; import { after, before, describe, it } from 'node:test'; import { handleGenerateNow, handleTriggerNow } from '../../dist/routes/eval-hub.js'; -import { setupHarnessFeedback, setupRawArtifacts } from './eval-manual-trigger-fixtures.js'; +import { setupHarnessFeedback } from './eval-manual-trigger-fixtures.js'; describe('Eval Manual Trigger Handlers (F192 OQ-21)', () => { /** @type {string} */ @@ -148,196 +148,360 @@ describe('Eval Manual Trigger Handlers (F192 OQ-21)', () => { }); // ========================================================================== - // handleGenerateNow — domain validation order + security + eval:a2a only + // KD-17 snapshot-first error paths (eval:harness-ledger manual trigger) // ========================================================================== - describe('handleGenerateNow', () => { - // 砚砚 R1 P2-a: validation order — unknown = 400 (not 501) - it('returns 400 for unknown domainId (eval:totally-unknown) — NOT 501', async () => { - const result = await handleGenerateNow( - { harnessFeedbackRoot: root }, + describe('handleTriggerNow KD-17 harness-ledger error paths', () => { + it('returns 503 when guardRejectionLog provider is absent for eval:harness-ledger', async () => { + const result = await handleTriggerNow( { - domainId: 'eval:totally-unknown', - userId: 'test-user', - verdictId: 'test', - snapshotName: 'foo.yaml', - attributionName: 'bar.yaml', + harnessFeedbackRoot: root, + invokeTriggerProvider: { get: () => ({ trigger: () => 'dispatched' }) }, + messageStore: { append: async () => ({ id: 'msg-hl' }) }, + // guardRejectionLog intentionally absent }, + { domainId: 'eval:harness-ledger', userId: 'test-user' }, ); - assert.ok('error' in result); - assert.equal(result.status, 400); - assert.match(result.error, /eval:totally-unknown.*not registered/); + assert.ok('error' in result, 'must return error when provider absent'); + assert.equal(result.status, 503); + assert.equal(result.error, 'harness_ledger_snapshot_unavailable'); + assert.ok(result.detail.includes('KD-17'), 'detail should reference KD-17'); }); - // 砚砚 R0 P1: 501 unsupported_generator for all registered-but-no-generator domains - it('returns 501 unsupported_generator for all non-a2a registered domains (NO stub)', async () => { - for (const domainId of ['eval:memory', 'eval:sop', 'eval:task-outcome', 'eval:capability-wakeup']) { - const result = await handleGenerateNow( - { harnessFeedbackRoot: root }, - { - domainId, - userId: 'test-user', - verdictId: 'test', - snapshotName: 'foo.yaml', - attributionName: 'bar.yaml', + it('returns 503 when snapshot production throws (Redis error)', async () => { + const throwingLog = { + queryWindowStrictComplete: async () => { + throw new Error('READONLY: Redis failover'); + }, + queryWindowStrict: async () => { + throw new Error('READONLY: Redis failover'); + }, + queryWindow: async () => [], + }; + const result = await handleTriggerNow( + { + harnessFeedbackRoot: root, + invokeTriggerProvider: { get: () => ({ trigger: () => 'dispatched' }) }, + messageStore: { append: async () => ({ id: 'msg-hl-err' }) }, + guardRejectionLog: throwingLog, + }, + { domainId: 'eval:harness-ledger', userId: 'test-user' }, + ); + assert.ok('error' in result, 'must return error when snapshot throws'); + assert.equal(result.status, 503); + assert.equal(result.error, 'harness_ledger_snapshot_failed'); + assert.ok(result.detail.includes('Redis failover'), 'detail should contain error message'); + }); + + it('invokes eval cat with evidence when snapshot succeeds', async () => { + const successEvents = [ + { eventId: 'e1', kind: 'hold_ball_429', guardId: 'guard-1', timestamp: Date.now(), rawPayload: {} }, + ]; + const successLog = { + queryWindowStrictComplete: async () => ({ events: successEvents, truncated: false }), + queryWindowStrict: async () => successEvents, + queryWindow: async () => [], + }; + const messageStoreCalls = []; + const result = await handleTriggerNow( + { + harnessFeedbackRoot: root, + invokeTriggerProvider: { get: () => ({ trigger: () => 'dispatched' }) }, + messageStore: { + append: async (msg) => { + messageStoreCalls.push(msg); + return { id: 'msg-hl-ok' }; + }, }, - ); - assert.ok('error' in result, `${domainId} expected error`); - assert.equal(result.status, 501, `${domainId} expected 501`); - assert.equal(result.error, 'unsupported_generator', `${domainId} expected unsupported_generator`); - assert.match(result.detail, /registered/, `${domainId} detail must confirm registered`); - } + guardRejectionLog: successLog, + }, + { domainId: 'eval:harness-ledger', userId: 'test-user' }, + ); + assert.ok(!('error' in result), `expected success, got: ${JSON.stringify(result)}`); + assert.equal(result.ok, true); + assert.equal(result.domainId, 'eval:harness-ledger'); + + // Delivered content must contain pre-computed evidence + assert.equal(messageStoreCalls.length, 1); + const content = messageStoreCalls[0].content; + assert.ok(content.includes('Pre-computed Guard Rejection Snapshot'), 'content should contain evidence'); + assert.ok(content.includes('evalRunId'), 'content should contain evalRunId'); + + // KD-17 last-hop: exact sourceRefs JSON must be in the delivered content. + // Eval cat copies this block verbatim — no ISO→epoch conversion needed. + assert.ok(content.includes('"windowStartMs"'), 'should contain exact windowStartMs'); + assert.ok(content.includes('"windowEndMs"'), 'should contain exact windowEndMs'); + const allJsonBlocks = [...content.matchAll(/```json\s*\n([\s\S]*?)\n\s*```/g)]; + const sourceRefsBlock = allJsonBlocks.find((m) => m[1].includes('"prompt-segments"')); + assert.ok(sourceRefsBlock, 'should have fenced JSON with sourceRefs'); + const sourceRefs = JSON.parse(sourceRefsBlock[1]); + assert.equal(sourceRefs.kind, 'prompt-segments'); + assert.equal(typeof sourceRefs.windowStartMs, 'number', 'windowStartMs must be number'); + assert.equal(typeof sourceRefs.windowEndMs, 'number', 'windowEndMs must be number'); + assert.ok(sourceRefs.windowEndMs > sourceRefs.windowStartMs, 'window must be valid'); + assert.ok(/^hlr-\d+-[a-f0-9]{8}$/.test(sourceRefs.evalRunId), 'evalRunId must match safe format'); }); + }); - it('returns 400 when required body fields missing for eval:a2a', async () => { - const result = await handleGenerateNow( - { harnessFeedbackRoot: root }, + // ========================================================================== + // F257 sub-item 1: zero events → skip invocation (eval:harness-ledger) + // ========================================================================== + describe('handleTriggerNow F257 zero-event skip', () => { + it('returns TriggerNowSkipped when snapshot has zero events (not an error)', async () => { + const emptyLog = { + queryWindowStrictComplete: async () => ({ events: [], truncated: false }), + queryWindowStrict: async () => [], + queryWindow: async () => [], + }; + const triggerCalls = []; + const result = await handleTriggerNow( { - domainId: 'eval:a2a', - userId: 'test-user', - snapshotName: 'foo.yaml', - attributionName: 'bar.yaml', + harnessFeedbackRoot: root, + invokeTriggerProvider: { + get: () => ({ + trigger: (...args) => { + triggerCalls.push(args); + return 'dispatched'; + }, + }), + }, + messageStore: { append: async () => ({ id: 'msg-zero' }) }, + guardRejectionLog: emptyLog, }, + { domainId: 'eval:harness-ledger', userId: 'test-user' }, ); - assert.ok('error' in result); - assert.equal(result.status, 400); + + // Must return ok + skipped (not an error, not a success with invocation) + assert.ok(!('error' in result), `expected skip, got error: ${JSON.stringify(result)}`); + assert.equal(result.ok, true); + assert.equal(result.skipped, true); + assert.equal(result.reason, 'zero_events_in_window'); + assert.ok(result.evalRunId, 'should include evalRunId for audit trail'); + assert.ok(/^hlr-\d+-[a-f0-9]{8}$/.test(result.evalRunId), 'evalRunId format'); + assert.ok(result.windowSummary.includes('0 events'), 'windowSummary should mention 0 events'); + + // Eval cat must NOT be triggered (nothing to evaluate) + assert.equal(triggerCalls.length, 0, 'invokeTrigger must NOT be called on zero events'); }); - // Cloud codex R3 P2: non-string body fields → 400 (NOT 500 from basename throw) - it('returns 400 for non-string body field values (NOT 500 from basename throw)', async () => { - const nonStringValues = [{ malicious: true }, 123, null, ['x'], true]; - for (const value of nonStringValues) { - for (const field of ['verdictId', 'snapshotName', 'attributionName']) { - const input = { - domainId: 'eval:a2a', - userId: 'test-user', - verdictId: 'test', - snapshotName: 'foo.yaml', - attributionName: 'bar.yaml', - }; - input[field] = value; - const result = await handleGenerateNow({ harnessFeedbackRoot: root }, input); - assert.ok('error' in result, `${field}=${JSON.stringify(value)} expected error`); - assert.equal(result.status, 400, `${field}=${JSON.stringify(value)} must be 400 not 500`); - } - } + it('still invokes eval cat when snapshot has events (>0)', async () => { + // Sanity check: non-zero events should proceed normally + const sanityEvents = [{ eventId: 'e1', kind: 'hold_ball_429', guardId: 'guard-1', timestamp: Date.now() }]; + const successLog = { + queryWindowStrictComplete: async () => ({ events: sanityEvents, truncated: false }), + queryWindowStrict: async () => sanityEvents, + queryWindow: async () => [], + }; + const result = await handleTriggerNow( + { + harnessFeedbackRoot: root, + invokeTriggerProvider: { get: () => ({ trigger: () => 'dispatched' }) }, + messageStore: { append: async () => ({ id: 'msg-with-events' }) }, + guardRejectionLog: successLog, + }, + { domainId: 'eval:harness-ledger', userId: 'test-user' }, + ); + + // Should NOT be skipped + assert.ok(!('error' in result), `expected success, got: ${JSON.stringify(result)}`); + assert.equal(result.ok, true); + assert.ok(!('skipped' in result), 'should NOT be skipped when events exist'); + assert.equal(result.invocationTriggered, true); }); + }); - // Cloud codex R4 P2: slug-invalid verdictId → 400 (NOT 500 from generator throw) - it('returns 400 for slug-invalid verdictId (NOT 500 from generator)', async () => { - const slugViolations = ['Test-Foo', 'test_foo', '-leading', 'foo.bar', 'foo bar', 'foo/bar']; - for (const value of slugViolations) { - const result = await handleGenerateNow( - { harnessFeedbackRoot: root }, - { - domainId: 'eval:a2a', - userId: 'test-user', - verdictId: value, - snapshotName: 'foo.yaml', - attributionName: 'bar.yaml', - }, - ); - assert.ok('error' in result, `'${value}' expected error`); - assert.equal(result.status, 400, `'${value}' must be 400 not 500`); - assert.match(result.error, /safe slug/i, `'${value}' error must mention safe slug`); - } + // ========================================================================== + // sol R10 P2-2 #2: manual trigger → snapshot query owner propagation + // ========================================================================== + describe('handleTriggerNow owner propagation (sol R10 P2-2)', () => { + it('passes input.userId as ownerUserId to snapshot query', async () => { + const queryCalls = []; + const spyLog = { + queryWindowStrictComplete: async (opts) => { + queryCalls.push(opts); + return { events: [], truncated: false }; + }, + queryWindowStrict: async () => [], + queryWindow: async () => [], + }; + + await handleTriggerNow( + { + harnessFeedbackRoot: root, + invokeTriggerProvider: { get: () => ({ trigger: () => 'dispatched' }) }, + messageStore: { append: async () => ({ id: 'msg-owner' }) }, + guardRejectionLog: spyLog, + }, + { domainId: 'eval:harness-ledger', userId: 'specific-owner-42' }, + ); + + assert.equal(queryCalls.length, 1, 'queryWindowStrictComplete must be called exactly once'); + assert.equal( + queryCalls[0].ownerUserId, + 'specific-owner-42', + 'snapshot query must receive input.userId as ownerUserId', + ); }); + }); - // 砚砚 R1 P1: path traversal in snapshotName/attributionName → 400 before any readFileSync - it('returns 400 before basename() for path-traversal in snapshotName/attributionName', async () => { - const traversalValues = ['../etc/passwd', '/etc/passwd', 'subdir/leak.yaml', '', '..', '.']; - for (const value of traversalValues) { - for (const field of ['snapshotName', 'attributionName']) { - const input = { - domainId: 'eval:a2a', - userId: 'test-user', - verdictId: 'test', - snapshotName: 'foo.yaml', - attributionName: 'bar.yaml', - }; - input[field] = value; - const result = await handleGenerateNow({ harnessFeedbackRoot: root }, input); - assert.ok('error' in result, `${field}='${value}' expected rejection`); - assert.equal(result.status, 400, `${field}='${value}' must be 400 not 500`); - assert.match(result.error, new RegExp(field), `${field}='${value}' error must call out ${field}`); - } - } + // ========================================================================== + // Sol R5 P2: escalationKind propagation — TriggerNowInput → snapshot seam + // ========================================================================== + describe('handleTriggerNow escalationKind propagation (sol R5 P2)', () => { + it('uncertainty_probe: persisted snapshot has escalationKind + content has warning', async () => { + const events = [ + { + eventId: 'e1', + kind: 'route_decision_skip', + guardId: 'a2a_route_decision_skip', + timestamp: Date.now(), + rawPayload: {}, + }, + ]; + const log = { + queryWindowStrictComplete: async () => ({ events, truncated: false }), + queryWindowStrict: async () => events, + queryWindow: async () => [], + }; + const messageStoreCalls = []; + const result = await handleTriggerNow( + { + harnessFeedbackRoot: root, + invokeTriggerProvider: { get: () => ({ trigger: () => 'dispatched' }) }, + messageStore: { + append: async (msg) => { + messageStoreCalls.push(msg); + return { id: 'msg-probe' }; + }, + }, + guardRejectionLog: log, + }, + { domainId: 'eval:harness-ledger', userId: 'test-user', escalationKind: 'uncertainty_probe' }, + ); + assert.ok(!('error' in result), `expected success, got: ${JSON.stringify(result)}`); + + // Verify persisted snapshot has escalationKind + const { join } = await import('node:path'); + const { readdirSync } = await import('node:fs'); + const snapshotsDir = join(root, 'run-snapshots'); + const files = readdirSync(snapshotsDir).filter((f) => f.endsWith('.json')); + // Find the latest snapshot (sorted by filename which starts with hlr-) + const latestFile = files.sort().pop(); + assert.ok(latestFile, 'snapshot file must exist'); + const snapshot = JSON.parse(readFileSync(join(snapshotsDir, latestFile), 'utf8')); + assert.equal(snapshot.escalationKind, 'uncertainty_probe', 'persisted snapshot must carry escalationKind'); + + // Verify content has UNCERTAINTY PROBE warning (summary injection) + assert.equal(messageStoreCalls.length, 1); + const content = messageStoreCalls[0].content; + assert.ok(content.includes('UNCERTAINTY PROBE'), 'content must include UNCERTAINTY PROBE warning'); }); - it('returns 500 when generator throws (valid basenames but missing files)', async () => { - const result = await handleGenerateNow( - { harnessFeedbackRoot: root }, + it('confirmed: persisted snapshot has escalationKind + content has no probe warning', async () => { + const events = [ { - domainId: 'eval:a2a', - userId: 'test-user', - verdictId: 'test-missing-files', - snapshotName: 'nonexistent-snapshot.yaml', - attributionName: 'nonexistent-attribution.yaml', + eventId: 'e2', + kind: 'route_decision_skip', + guardId: 'a2a_route_decision_skip', + timestamp: Date.now(), + rawPayload: {}, }, + ]; + const log = { + queryWindowStrictComplete: async () => ({ events, truncated: false }), + queryWindowStrict: async () => events, + queryWindow: async () => [], + }; + const messageStoreCalls = []; + const result = await handleTriggerNow( + { + harnessFeedbackRoot: root, + invokeTriggerProvider: { get: () => ({ trigger: () => 'dispatched' }) }, + messageStore: { + append: async (msg) => { + messageStoreCalls.push(msg); + return { id: 'msg-confirmed' }; + }, + }, + guardRejectionLog: log, + }, + { domainId: 'eval:harness-ledger', userId: 'test-user', escalationKind: 'confirmed' }, ); - assert.ok('error' in result); - assert.equal(result.status, 500); - assert.match(result.error, /Generator failed/); + assert.ok(!('error' in result), `expected success, got: ${JSON.stringify(result)}`); + + // Verify persisted snapshot has escalationKind + const { join } = await import('node:path'); + const { readdirSync } = await import('node:fs'); + const snapshotsDir = join(root, 'run-snapshots'); + const files = readdirSync(snapshotsDir).filter((f) => f.endsWith('.json')); + const latestFile = files.sort().pop(); + assert.ok(latestFile, 'snapshot file must exist'); + const snapshot = JSON.parse(readFileSync(join(snapshotsDir, latestFile), 'utf8')); + assert.equal(snapshot.escalationKind, 'confirmed', 'persisted snapshot must carry confirmed'); + + // Verify content does NOT have probe warning + assert.equal(messageStoreCalls.length, 1); + const content = messageStoreCalls[0].content; + assert.ok(!content.includes('UNCERTAINTY PROBE'), 'confirmed must NOT include probe warning'); }); - // 砚砚 R0 P1: e2e roundtrip — generated verdict appears in Hub summary - it('eval:a2a generates verdict + roundtrips through loadEvalHubSummary()', async () => { - const { snapshotName, attributionName } = setupRawArtifacts(root, '2026-06-04'); - const verdictId = '2026-06-04-eval-a2a-roundtrip-test'; + it('absent: persisted snapshot has no escalationKind when not provided', async () => { + const events = [ + { + eventId: 'e3', + kind: 'route_decision_skip', + guardId: 'a2a_route_decision_skip', + timestamp: Date.now(), + rawPayload: {}, + }, + ]; + const log = { + queryWindowStrictComplete: async () => ({ events, truncated: false }), + queryWindowStrict: async () => events, + queryWindow: async () => [], + }; + const result = await handleTriggerNow( + { + harnessFeedbackRoot: root, + invokeTriggerProvider: { get: () => ({ trigger: () => 'dispatched' }) }, + messageStore: { append: async () => ({ id: 'msg-absent' }) }, + guardRejectionLog: log, + }, + { domainId: 'eval:harness-ledger', userId: 'test-user' }, + ); + assert.ok(!('error' in result), `expected success, got: ${JSON.stringify(result)}`); + + // Verify persisted snapshot has NO escalationKind + const { join } = await import('node:path'); + const { readdirSync } = await import('node:fs'); + const snapshotsDir = join(root, 'run-snapshots'); + const files = readdirSync(snapshotsDir).filter((f) => f.endsWith('.json')); + const latestFile = files.sort().pop(); + assert.ok(latestFile, 'snapshot file must exist'); + const snapshot = JSON.parse(readFileSync(join(snapshotsDir, latestFile), 'utf8')); + assert.equal(snapshot.escalationKind, undefined, 'no escalationKind when not provided'); + }); + }); + // ========================================================================== + // handleGenerateNow — retired product-worktree writer + // ========================================================================== + describe('handleGenerateNow', () => { + it('always returns 410 before reading evidence or writing product Git files', async () => { const result = await handleGenerateNow( { harnessFeedbackRoot: root }, { domainId: 'eval:a2a', userId: 'test-user', - verdictId, - snapshotName, - attributionName, + verdictId: 'legacy-verdict', + snapshotName: '../must-not-read.yaml', + attributionName: '../must-not-read.yaml', }, ); - assert.ok(!('error' in result), `Expected success, got: ${JSON.stringify(result)}`); - assert.equal(result.ok, true); - assert.equal(result.domainId, 'eval:a2a'); - assert.equal(result.verdictId, verdictId); - assert.ok(result.verdictPath.endsWith(`${verdictId}.md`)); - assert.ok(result.bundleDir.includes(verdictId)); - assert.equal(result.hubRoundtrip.ok, true, `roundtrip failed: ${JSON.stringify(result.hubRoundtrip)}`); - assert.ok(result.hubRoundtrip.itemCount >= 1); - }); - - // Cloud codex R10 P1 + 砚砚收敛 A: idempotency — duplicate verdictId → 409, no overwrite - it('rejects duplicate verdictId with 409 + does NOT overwrite (砚砚 R10)', async () => { - const { snapshotName, attributionName } = setupRawArtifacts(root, '2026-06-05'); - const verdictId = '2026-06-05-eval-a2a-idempotency-test'; - const input = { domainId: 'eval:a2a', userId: 'test-user', verdictId, snapshotName, attributionName }; - const first = await handleGenerateNow({ harnessFeedbackRoot: root }, input); - assert.ok(!('error' in first), `first should succeed: ${JSON.stringify(first)}`); - const original = readFileSync(first.verdictPath, 'utf8'); - const second = await handleGenerateNow({ harnessFeedbackRoot: root }, input); - assert.ok('error' in second); - assert.equal(second.status, 409); - assert.equal(second.error, 'verdict_already_exists'); - assert.match(second.detail, /forbidden|data integrity/i); - assert.equal(readFileSync(first.verdictPath, 'utf8'), original, 'verdict must NOT be overwritten'); - }); - - // 砚砚收敛 A: length limits — prevent DoS via huge inputs - it('rejects oversized verdictId/snapshotName/attributionName with 400 (砚砚 R10)', async () => { - const big = 'a'.repeat(300); - const base = { - domainId: 'eval:a2a', - userId: 'test-user', - verdictId: 'ok', - snapshotName: 'foo.yaml', - attributionName: 'bar.yaml', - }; - for (const field of ['verdictId', 'snapshotName', 'attributionName']) { - const value = field === 'verdictId' ? big : `${big}.yaml`; - const result = await handleGenerateNow({ harnessFeedbackRoot: root }, { ...base, [field]: value }); - assert.ok('error' in result, `${field}=oversized expected rejection`); - assert.equal(result.status, 400, `${field}=oversized must be 400`); - assert.match(result.error, new RegExp(field), `${field} error must call out ${field}`); - } + assert.equal(result.status, 410); + assert.equal(result.error, 'generate_now_sunset'); + assert.match(result.detail, /durable artifact store/i); + assert.match(result.detail, /does not create Git commits, branches, or PRs/i); }); }); }); diff --git a/packages/api/test/harness-eval/git-worktree-publisher.test.js b/packages/api/test/harness-eval/git-worktree-publisher.test.js deleted file mode 100644 index 1a595d54ec..0000000000 --- a/packages/api/test/harness-eval/git-worktree-publisher.test.js +++ /dev/null @@ -1,117 +0,0 @@ -import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; -import fs, { rmSync, writeFileSync } from 'node:fs'; -import { syncBuiltinESMExports } from 'node:module'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { afterEach, describe, it } from 'node:test'; - -function createRepoWithOrigin() { - const repoRoot = fs.mkdtempSync(join(tmpdir(), 'publish-wt-repo-')); - const remoteRoot = fs.mkdtempSync(join(tmpdir(), 'publish-wt-remote-')); - execFileSync('git', ['init', '-b', 'main'], { cwd: repoRoot, stdio: 'ignore' }); - execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoRoot, stdio: 'ignore' }); - execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: repoRoot, stdio: 'ignore' }); - writeFileSync(join(repoRoot, 'README.md'), '# test\n'); - execFileSync('git', ['add', 'README.md'], { cwd: repoRoot, stdio: 'ignore' }); - execFileSync('git', ['commit', '-m', 'init'], { cwd: repoRoot, stdio: 'ignore' }); - execFileSync('git', ['init', '--bare', remoteRoot], { stdio: 'ignore' }); - execFileSync('git', ['remote', 'add', 'origin', remoteRoot], { cwd: repoRoot, stdio: 'ignore' }); - execFileSync('git', ['push', '-u', 'origin', 'main'], { cwd: repoRoot, stdio: 'ignore' }); - execFileSync('git', ['fetch', 'origin', 'main'], { cwd: repoRoot, stdio: 'ignore' }); - return { repoRoot, remoteRoot }; -} - -function branchExists(repoRoot, branchName) { - try { - execFileSync('git', ['rev-parse', '--verify', `refs/heads/${branchName}`], { - cwd: repoRoot, - stdio: 'ignore', - }); - return true; - } catch { - return false; - } -} - -afterEach(() => { - syncBuiltinESMExports(); -}); - -describe('createGitWorktreePublisher', () => { - it('cleans up a partially-created local branch when worktree add fails before stage', async (t) => { - const { repoRoot, remoteRoot } = createRepoWithOrigin(); - const worktreePath = fs.mkdtempSync(join(tmpdir(), 'publish-wt-target-')); - writeFileSync(join(worktreePath, 'non-empty.txt'), 'trigger partial failure\n'); - const branchName = 'verdict/auto/eval-task-outcome/partial-fail-cleanup'; - - t.mock.method(fs, 'mkdtempSync', () => worktreePath); - syncBuiltinESMExports(); - - try { - const { createGitWorktreePublisher } = await import( - `../../dist/infrastructure/harness-eval/publish-verdict/git-worktree-publisher.js?t=${Date.now()}` - ); - const publisher = createGitWorktreePublisher({ repoRoot }); - - await assert.rejects( - publisher.publishOnIsolatedWorktree({ - branchName, - sourceBase: 'origin/main', - stage: async () => { - throw new Error('stage should not run when worktree add fails'); - }, - }), - ); - - assert.equal( - branchExists(repoRoot, branchName), - false, - 'partial worktree-add failure must not leak a local branch', - ); - } finally { - rmSync(repoRoot, { recursive: true, force: true }); - rmSync(remoteRoot, { recursive: true, force: true }); - rmSync(worktreePath, { recursive: true, force: true }); - } - }); - - it('does not delete a branch that already existed before the publish attempt', async (t) => { - const { repoRoot, remoteRoot } = createRepoWithOrigin(); - const branchName = 'verdict/auto/eval-task-outcome/pre-existing-branch'; - execFileSync('git', ['branch', branchName, 'HEAD'], { cwd: repoRoot, stdio: 'ignore' }); - - const worktreePath = fs.mkdtempSync(join(tmpdir(), 'publish-wt-target-')); - writeFileSync(join(worktreePath, 'non-empty.txt'), 'trigger failure without ownership\n'); - - t.mock.method(fs, 'mkdtempSync', () => worktreePath); - syncBuiltinESMExports(); - - try { - const { createGitWorktreePublisher } = await import( - `../../dist/infrastructure/harness-eval/publish-verdict/git-worktree-publisher.js?t=${Date.now()}-keep` - ); - const publisher = createGitWorktreePublisher({ repoRoot }); - - await assert.rejects( - publisher.publishOnIsolatedWorktree({ - branchName, - sourceBase: 'origin/main', - stage: async () => { - throw new Error('stage should not run when worktree add fails'); - }, - }), - ); - - assert.equal( - branchExists(repoRoot, branchName), - true, - 'cleanup must not delete a branch that predates this publish attempt', - ); - } finally { - rmSync(repoRoot, { recursive: true, force: true }); - rmSync(remoteRoot, { recursive: true, force: true }); - rmSync(worktreePath, { recursive: true, force: true }); - } - }); -}); diff --git a/packages/api/test/harness-eval/guard-anomaly-adapter.test.js b/packages/api/test/harness-eval/guard-anomaly-adapter.test.js new file mode 100644 index 0000000000..4e9d1c22eb --- /dev/null +++ b/packages/api/test/harness-eval/guard-anomaly-adapter.test.js @@ -0,0 +1,212 @@ +/** + * F257 V2/Phase B — guard-anomaly friction adapter (5th channel) + stats. + * + * Contract under test: + * - manual_observation notes referencing a registered pot ledgerId produce + * deterministic FrictionSignals (idempotent id: guard-anomaly:#) + * - condition_hit events and non-referencing notes are excluded + * - pagination followed to exhaustion (never silently truncated) + * - pull is READ-ONLY (F245 KD-4) — no stats mutation from the adapter + * - GuardLedgerStats: SADD idempotency (dedup replay never double-counts) + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import { GuardAnomalyAdapter } from '../../dist/infrastructure/harness-eval/friction/guard-anomaly-adapter.js'; +import { extractLedgerRefs, GuardLedgerStats } from '../../dist/infrastructure/harness-eval/guard-ledger-registry.js'; + +const T = 1700000000000; + +function manualEvent(over = {}) { + return { + kind: 'manual_observation', + eventId: `dev-${over.seq ?? 1}`, + timestamp: T, + registryVersion: 'none', + incidentKey: `ik-${over.seq ?? 1}`, + ownerUserId: 'default-user', + attributions: [{ objectiveId: 'obj-x', unitRefs: [{ unitType: 'segment', unitId: 'S1' }], weight: 1 }], + anchors: { threadId: 'thread_a' }, + subjectCatId: 'codex', + source: 'self', + note: 'hit a 429; rejection said [ledger: mcp/hold-ball-rate-limit]', + sourceAnchor: { kind: 'thread_message', messageId: 'm1' }, + recordedBy: 'codex', + ...over, + }; +} + +function fakeLog(pages) { + const calls = []; + return { + calls, + async query(input) { + calls.push(input); + return pages[calls.length - 1] ?? { events: [], nextCursor: null }; + }, + }; +} + +describe('GuardAnomalyAdapter — 5th friction channel', () => { + test('extracts one signal per referenced pot with deterministic idempotent id', async () => { + const log = fakeLog([ + { + events: [ + manualEvent({ seq: 1 }), + manualEvent({ + seq: 2, + note: 'both mcp/hold-ball-rate-limit and mcp/cross-post-routing-credentials rejected me', + }), + manualEvent({ seq: 3, note: 'no pot reference here' }), + { + ...manualEvent({ seq: 4 }), + kind: 'condition_hit', + conditionId: 'c1', + sourceFactRef: 'f1', + recordedBy: 'system', + }, + ], + nextCursor: null, + }, + ]); + const adapter = new GuardAnomalyAdapter({ deviationLog: log, ownerUserId: 'default-user' }); + + const signals = await adapter.pull(T - 1000, T + 1000); + + assert.equal(signals.length, 3, 'ev1 (1 ref) + ev2 (2 refs); non-referencing + condition_hit excluded'); + assert.equal(signals[0].id, 'guard-anomaly:dev-1#mcp/hold-ball-rate-limit'); + assert.equal(signals[0].channel, 'guard-anomaly'); + assert.equal(signals[0].catId, 'codex'); + assert.equal(signals[0].threadId, 'thread_a'); + assert.ok(signals[0].symptom.includes('mcp/hold-ball-rate-limit')); + const ev2Ids = signals.filter((s) => s.rawRef.startsWith('dev-2#')).map((s) => s.id); + assert.deepEqual( + new Set(ev2Ids), + new Set([ + 'guard-anomaly:dev-2#mcp/hold-ball-rate-limit', + 'guard-anomaly:dev-2#mcp/cross-post-routing-credentials', + ]), + ); + + // Same window pulled again → identical ids (idempotency contract). + const log2 = fakeLog([{ events: [manualEvent({ seq: 1 })], nextCursor: null }]); + const adapter2 = new GuardAnomalyAdapter({ deviationLog: log2, ownerUserId: 'default-user' }); + const again = await adapter2.pull(T - 1000, T + 1000); + assert.equal(again[0].id, signals[0].id); + }); + + test('follows pagination to exhaustion and windows the query correctly', async () => { + const log = fakeLog([ + { events: [manualEvent({ seq: 1 })], nextCursor: 'page2' }, + { events: [manualEvent({ seq: 2 })], nextCursor: null }, + ]); + const adapter = new GuardAnomalyAdapter({ deviationLog: log, ownerUserId: 'default-user' }); + + const signals = await adapter.pull(T, T + 5000); + + assert.equal(signals.length, 2, 'both pages consumed'); + assert.equal(log.calls.length, 2); + assert.equal(log.calls[0].ownerUserId, 'default-user'); + assert.equal(log.calls[0].fromMs, T); + assert.equal(log.calls[0].toMs, T + 4999, 'adapter [since, until) → inclusive toMs = until-1'); + assert.equal(log.calls[1].cursor, 'page2'); + }); +}); + +describe('extractLedgerRefs — token-boundary matching (sol P2-2)', () => { + test('matches only registered coordinates, no false positives', () => { + assert.deepEqual(extractLedgerRefs('nothing here'), []); + assert.deepEqual(extractLedgerRefs('saw mcp/hold-ball-rate-limit today'), ['mcp/hold-ball-rate-limit']); + assert.deepEqual( + extractLedgerRefs('unregistered mcp/made-up-pot ref'), + [], + 'unregistered pots have no stats identity', + ); + }); + + test('suffix/prefix extensions do NOT attribute to the legitimate pot', () => { + assert.deepEqual( + extractLedgerRefs('saw mcp/hold-ball-rate-limit-evil today'), + [], + 'suffix extension must not match (bare substring bug)', + ); + assert.deepEqual(extractLedgerRefs('xmcp/hold-ball-rate-limit'), [], 'prefix extension must not match'); + assert.deepEqual( + extractLedgerRefs('mcp/hold-ball-rate-limit/extra'), + [], + 'deeper path must not match the shorter pot', + ); + }); + + test('boundary punctuation and edges still match', () => { + assert.deepEqual(extractLedgerRefs('mcp/hold-ball-rate-limit'), ['mcp/hold-ball-rate-limit'], 'exact string'); + assert.deepEqual( + extractLedgerRefs('[ledger: mcp/hold-ball-rate-limit]'), + ['mcp/hold-ball-rate-limit'], + 'bracketed rejection-response format', + ); + assert.deepEqual( + extractLedgerRefs('(撞到 mcp/hold-ball-rate-limit,已重试)'), + ['mcp/hold-ball-rate-limit'], + 'CJK punctuation neighbors', + ); + }); +}); + +describe('GuardLedgerStats — idempotent AC-B2 writeback', () => { + function fakeRedis() { + const sets = new Map(); + return { + sets, + async sadd(key, member) { + const s = sets.get(key) ?? new Set(); + const before = s.size; + s.add(member); + sets.set(key, s); + return s.size - before; + }, + async scard(key) { + return sets.get(key)?.size ?? 0; + }, + }; + } + + test('SADD of the same (pot, eventId) never double-counts; distinct events accumulate', async () => { + const redis = fakeRedis(); + const stats = new GuardLedgerStats(redis); + + await stats.recordAnomalyReference('default-user', 'mcp/hold-ball-rate-limit', 'dev-1'); + await stats.recordAnomalyReference('default-user', 'mcp/hold-ball-rate-limit', 'dev-1'); // dedup replay + await stats.recordAnomalyReference('default-user', 'mcp/hold-ball-rate-limit', 'dev-2'); + + assert.equal(await stats.anomalyReferenceCount('default-user', 'mcp/hold-ball-rate-limit'), 2); + assert.equal(await stats.anomalyReferenceCount('default-user', 'mcp/never-referenced'), 0); + }); + + test('write-side fail-open: redis sadd errors do not reject', async () => { + const stats = new GuardLedgerStats({ + async sadd() { + throw new Error('down'); + }, + async scard() { + throw new Error('down'); + }, + }); + await assert.doesNotReject(() => stats.recordAnomalyReference('default-user', 'mcp/hold-ball-rate-limit', 'dev-1')); + }); + + test('read-side fail-closed: redis scard errors propagate (sol P2-3)', async () => { + const stats = new GuardLedgerStats({ + async sadd() { + return 1; + }, + async scard() { + throw new Error('READONLY: Redis failover'); + }, + }); + await assert.rejects(() => stats.anomalyReferenceCount('default-user', 'mcp/hold-ball-rate-limit'), { + message: 'READONLY: Redis failover', + }); + }); +}); diff --git a/packages/api/test/harness-eval/guard-drift-guard.test.js b/packages/api/test/harness-eval/guard-drift-guard.test.js new file mode 100644 index 0000000000..e7fd695d2e --- /dev/null +++ b/packages/api/test/harness-eval/guard-drift-guard.test.js @@ -0,0 +1,154 @@ +/** + * F257 V2/Phase B — differential drift guard. + * + * Property test: coalesceGuardEpisodes (full coalescer) and + * EpisodeBoundaryTracker (streaming state machine) must always agree + * on episode count for the same input — 500 random seeds. + * + * Fable ruling: "differential guard 落盘" — if the two ever diverge, + * it means the state machine was not correctly absorbed into the coalescer + * (or vice versa), and the dual implementation drift is back. + * + * [opus/claude-opus-4-6🐾] + */ + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + coalesceGuardEpisodes, + EPISODE_GAP_MS, + EpisodeBoundaryTracker, +} from '../../dist/infrastructure/harness-eval/guard-episode-coalescing.js'; + +import { rawEvent, T } from './_guard-test-helpers.js'; + +// --------------------------------------------------------------------------- +// Seeded PRNG (xorshift32) for reproducible random tests +// --------------------------------------------------------------------------- + +function xorshift32(seed) { + let state = seed | 1; + return () => { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + return (state >>> 0) / 0xffffffff; + }; +} + +// --------------------------------------------------------------------------- +// Random event generator +// --------------------------------------------------------------------------- + +const GUARD_IDS = ['hold_ball_rate_limit', 'a2a_pingpong_block', 'schema_reject']; +const THREAD_IDS = ['thread_1', 'thread_2', 'thread_3', 'thread_4']; +const CAT_IDS = ['cat_1', 'cat_2', 'cat_3']; +const UNTRUSTED = ['', 'unknown']; + +function generateRandomEvents(rand, count) { + const events = []; + for (let i = 0; i < count; i++) { + const useUntrusted = rand() < 0.1; + const guardId = + useUntrusted && rand() < 0.5 + ? UNTRUSTED[Math.floor(rand() * UNTRUSTED.length)] + : GUARD_IDS[Math.floor(rand() * GUARD_IDS.length)]; + const threadId = + useUntrusted && rand() < 0.3 + ? UNTRUSTED[Math.floor(rand() * UNTRUSTED.length)] + : THREAD_IDS[Math.floor(rand() * THREAD_IDS.length)]; + const catId = CAT_IDS[Math.floor(rand() * CAT_IDS.length)]; + // 40% within-gap clusters, 60% across-gap + const gapScale = rand() < 0.4 ? 1000 : 200_000; + const timestamp = T + Math.floor(rand() * 50) * gapScale + Math.floor(rand() * 500); + events.push(rawEvent({ timestamp, seq: i, eventId: `drift-${i}-${timestamp}`, guardId, threadId, catId })); + } + return events; +} + +/** Sort events the same way coalesceGuardEpisodes does internally. */ +function sortEvents(events) { + return [...events].sort( + (a, b) => a.timestamp - b.timestamp || (a.eventId < b.eventId ? -1 : a.eventId > b.eventId ? 1 : 0), + ); +} + +/** Feed sorted events through tracker, return lowerBound. */ +function trackerCount(sorted, gapMs = EPISODE_GAP_MS) { + const tracker = new EpisodeBoundaryTracker(gapMs); + for (const event of sorted) tracker.feed(event); + return tracker.lowerBound; +} + +// --------------------------------------------------------------------------- +// Explicit edge cases +// --------------------------------------------------------------------------- + +describe('drift guard — edge cases', () => { + it('empty input → 0 episodes for both paths', () => { + assert.equal(coalesceGuardEpisodes([]).length, 0); + assert.equal(trackerCount([]), 0); + }); + + it('single event → 1 episode for both paths', () => { + const events = [rawEvent()]; + assert.equal(coalesceGuardEpisodes(events).length, 1); + assert.equal(trackerCount(sortEvents(events)), 1); + }); + + it('all same key, within gap → 1 episode for both paths', () => { + const events = Array.from({ length: 10 }, (_, i) => rawEvent({ timestamp: T + i * 1000, seq: i })); + assert.equal(coalesceGuardEpisodes(events).length, 1); + assert.equal(trackerCount(sortEvents(events)), 1); + }); + + it('all different keys → N episodes for both paths', () => { + const events = Array.from({ length: 5 }, (_, i) => + rawEvent({ timestamp: T + i * 1000, seq: i, threadId: `thread_${i}`, catId: `cat_${i}` }), + ); + assert.equal(coalesceGuardEpisodes(events).length, 5); + assert.equal(trackerCount(sortEvents(events)), 5); + }); + + it('untrusted keys → each forms solo episode for both paths', () => { + const events = [ + rawEvent({ timestamp: T, seq: 0, threadId: '' }), + rawEvent({ timestamp: T + 100, seq: 1, threadId: '' }), + rawEvent({ timestamp: T + 200, seq: 2, catId: 'unknown' }), + ]; + assert.equal(coalesceGuardEpisodes(events).length, 3); + assert.equal(trackerCount(sortEvents(events)), 3); + }); +}); + +// --------------------------------------------------------------------------- +// 500-seed property test +// --------------------------------------------------------------------------- + +describe('drift guard — 500-seed property test', () => { + it('tracker.lowerBound === coalescer.length for 500 random event sets', () => { + let failures = 0; + const firstFailure = { seed: -1, msg: '' }; + + for (let seed = 1; seed <= 500; seed++) { + const rand = xorshift32(seed); + const count = Math.floor(rand() * 50) + 1; + const events = generateRandomEvents(rand, count); + + const coalescerCount = coalesceGuardEpisodes(events).length; + const sorted = sortEvents(events); + const lb = trackerCount(sorted); + + if (lb !== coalescerCount) { + failures++; + if (firstFailure.seed === -1) { + firstFailure.seed = seed; + firstFailure.msg = `seed=${seed}: tracker=${lb} coalescer=${coalescerCount} events=${count}`; + } + } + } + + assert.equal(failures, 0, `${failures} seeds failed. First: ${firstFailure.msg}`); + }); +}); diff --git a/packages/api/test/harness-eval/guard-emit-points.test.js b/packages/api/test/harness-eval/guard-emit-points.test.js new file mode 100644 index 0000000000..fe4dd50e59 --- /dev/null +++ b/packages/api/test/harness-eval/guard-emit-points.test.js @@ -0,0 +1,211 @@ +/** + * F257 V2/Phase B — API-route emit points behavior (AC-B1 route-layer side). + * + * Focus: the CONDITIONAL emit semantics on the hold-ball schema 400 — + * only the ungrounded-timer reject (wakeAfterMs without waitSourceRef, + * the PR-O3 structural pot) is a pot firing; ordinary schema violations + * are plain input errors and must NOT enter the ledger. + * + * The 429 emit is covered by the coalescing/escalation suites; skip / + * gate-keeping / publish-403 emits are same-shape mechanical wiring + * (declared in the PR body for reviewer verification). + */ + +import assert from 'node:assert/strict'; +import { beforeEach, describe, mock, test } from 'node:test'; +import Fastify from 'fastify'; + +describe('F257 V2: hold-ball route conditional guard emit', () => { + let registry; + let threadStore; + + beforeEach(async () => { + const { InvocationRegistry } = await import( + '../../dist/domains/cats/services/agents/invocation/InvocationRegistry.js' + ); + const { ThreadStore } = await import('../../dist/domains/cats/services/stores/ports/ThreadStore.js'); + registry = new InvocationRegistry(); + threadStore = new ThreadStore(); + }); + + function makeFakeLog() { + const appended = []; + return { + append: mock.fn(async (event) => { + appended.push(event); + }), + _appended: appended, + }; + } + + async function createApp(guardRejectionLog, holdBallExtra = {}) { + const { callbacksRoutes } = await import('../../dist/routes/callbacks.js'); + const app = Fastify(); + await app.register(callbacksRoutes, { + registry, + messageStore: { + async getMessagesForThread() { + return []; + }, + }, + socketManager: { + broadcastAgentMessage() {}, + getMessages() { + return []; + }, + }, + threadStore, + evidenceStore: { + async store() {}, + async search() { + return []; + }, + }, + markerQueue: { enqueue() {} }, + reflectionService: { async run() {} }, + holdBallDeps: { + registry, + taskRunner: { registerDynamic() {}, unregister() {} }, + templateRegistry: { get() {} }, + dynamicTaskStore: { insert() {}, getAll: () => [], remove: () => true }, + messageStore: { async append() {} }, + socketManager: { broadcastToRoom() {} }, + guardRejectionLog, + ...holdBallExtra, + }, + }); + return app; + } + + test('ungrounded timer 400 emits http_schema_reject with full octet + response ledgerId', async () => { + const log = makeFakeLog(); + const app = await createApp(log); + const thread = await threadStore.create('user-ep-1', 'ep1'); + const { invocationId, callbackToken } = await registry.create('user-ep-1', 'codex', thread.id); + + const response = await app.inject({ + method: 'POST', + url: '/api/callbacks/hold-ball', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + // wakeAfterMs WITHOUT waitSourceRef — the PR-O3 structural pot. + payload: { reason: 'waiting for CI', nextStep: 'check build', wakeAfterMs: 60_000 }, + }); + + assert.equal(response.statusCode, 400); + const body = JSON.parse(response.body); + assert.equal(body.ledgerId, 'mcp/hold-ball-wait-source-ref', 'rejection response carries the pot coordinate'); + + // Fire-and-forget append — give it a tick to settle. + await new Promise((r) => setTimeout(r, 20)); + assert.equal(log._appended.length, 1, 'exactly one guard event for the pot firing'); + const event = log._appended[0]; + assert.equal(event.kind, 'http_schema_reject'); + assert.equal(event.guardId, 'hold_ball_wait_source_ref'); + assert.equal(event.ledgerId, 'mcp/hold-ball-wait-source-ref'); + assert.equal(event.catId, 'codex'); + assert.equal(event.threadId, thread.id); + assert.equal(event.invocationId, invocationId, 'route handler has the real invocationId'); + assert.equal(event.correlationConfidence, 'exact'); + assert.equal(event.sourceTool, 'hold_ball'); + assert.equal(event.normalizedReason, 'missing_wait_source_ref'); + assert.equal(event.layer, 'api-route'); + }); + + test('sol P2-5: gate-keeping blocked emits http_policy_reject with octet + response ledgerId', async () => { + const log = makeFakeLog(); + const thread = await threadStore.create('user-gk-1', 'gk1'); + // checkGateKeepingGuard reads threadKind via the HOLD-BALL deps' own + // threadStore — inject one that marks this thread as gate-keeping. + const app = await createApp(log, { + threadStore: { get: async (id) => (id === thread.id ? { id, threadKind: 'gate-keeping' } : null) }, + }); + const { invocationId, callbackToken } = await registry.create('user-gk-1', 'codex', thread.id); + + const response = await app.inject({ + method: 'POST', + url: '/api/callbacks/hold-ball', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { + reason: 'waiting for external CI on gate-keeping thread', + nextStep: 'check result', + wakeAfterMs: 3_600_000, + waitSourceRef: { + kind: 'github_issue', + value: 'org/repo#1', + expectedSignal: 'closed', + slaUntilMs: Date.now() + 3_600_000, + }, + }, + }); + + assert.equal(response.statusCode, 400, `expected gate-keeping block, got ${response.statusCode}: ${response.body}`); + const body = JSON.parse(response.body); + assert.equal(body.error, 'gate_keeping_thread_default_blocked'); + assert.equal(body.ledgerId, 'mcp/gate-keeping-thread-default', 'blocked response carries the pot coordinate'); + + await new Promise((r) => setTimeout(r, 20)); + assert.equal(log._appended.length, 1, 'gate-keeping block must emit exactly one guard event'); + const event = log._appended[0]; + assert.equal(event.kind, 'http_policy_reject'); + assert.equal(event.guardId, 'gate_keeping_thread_default'); + assert.equal(event.ledgerId, 'mcp/gate-keeping-thread-default'); + assert.equal(event.catId, 'codex'); + assert.equal(event.threadId, thread.id); + assert.equal(event.invocationId, invocationId); + assert.equal(event.correlationConfidence, 'exact'); + assert.equal(event.sourceTool, 'hold_ball'); + assert.equal(event.layer, 'api-route'); + }); + + test('sol P2-5 counter-example: non-gate-keeping thread with same payload does NOT emit policy event', async () => { + const log = makeFakeLog(); + const app = await createApp(log); + const thread = await threadStore.create('user-gk-2', 'gk2'); // ordinary thread + const { invocationId, callbackToken } = await registry.create('user-gk-2', 'codex', thread.id); + + const response = await app.inject({ + method: 'POST', + url: '/api/callbacks/hold-ball', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { + reason: 'same payload, ordinary thread', + nextStep: 'check result', + wakeAfterMs: 600_000, + waitSourceRef: { + kind: 'github_issue', + value: 'org/repo#1', + expectedSignal: 'closed', + slaUntilMs: Date.now() + 3_600_000, + }, + }, + }); + + // Ordinary thread passes the gate — whatever the final status, no + // http_policy_reject may be emitted for it. + const policyEvents = log._appended.filter((e) => e.kind === 'http_policy_reject'); + assert.equal(policyEvents.length, 0, `no policy event on pass path (status was ${response.statusCode})`); + }); + + test('ordinary schema 400 (missing reason, wakeWhen mode) does NOT emit — not a pot', async () => { + const log = makeFakeLog(); + const app = await createApp(log); + const thread = await threadStore.create('user-ep-2', 'ep2'); + const { invocationId, callbackToken } = await registry.create('user-ep-2', 'codex', thread.id); + + const response = await app.inject({ + method: 'POST', + url: '/api/callbacks/hold-ball', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + // Missing `reason` — a plain input error. wakeWhen mode is self-grounded, + // so the ungrounded-timer condition does not apply. + payload: { nextStep: 'check build', wakeWhen: { command: 'echo ok' } }, + }); + + assert.equal(response.statusCode, 400); + const body = JSON.parse(response.body); + assert.equal(body.ledgerId, undefined, 'no pot coordinate on ordinary input errors'); + + await new Promise((r) => setTimeout(r, 20)); + assert.equal(log._appended.length, 0, 'ordinary schema violations must not enter the ledger'); + }); +}); diff --git a/packages/api/test/harness-eval/guard-episode-coalescing.test.js b/packages/api/test/harness-eval/guard-episode-coalescing.test.js new file mode 100644 index 0000000000..6fd9139278 --- /dev/null +++ b/packages/api/test/harness-eval/guard-episode-coalescing.test.js @@ -0,0 +1,667 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it, mock } from 'node:test'; + +import { + coalesceGuardEpisodes, + EPISODE_GAP_MS, +} from '../../dist/infrastructure/harness-eval/guard-episode-coalescing.js'; +import { checkGuardThreshold } from '../../dist/infrastructure/harness-eval/guard-threshold-escalation.js'; +import { produceHarnessLedgerRunSnapshot } from '../../dist/infrastructure/harness-eval/harness-ledger-snapshot-provider.js'; +import { createHarnessLedgerGeneratorAdapter } from '../../dist/infrastructure/harness-eval/publish-verdict/harness-ledger-generator-adapter.js'; +import { createFakeEventSource, createFakeRedis, rawEvent, T, triggerSuccess } from './_guard-test-helpers.js'; + +// --------------------------------------------------------------------------- +// F257 V2/Phase B — PR #41 verdict regression (episode coalescing). +// +// Verdict 2026-07-19-harness-ledger-burst-coalescing-fix-c2 (MERGED, fix): +// "Change F257 escalation accounting to preserve rawEventCount but coalesce +// same-guard, same-thread, same-cat rapid retries into a distinct episode +// count used by the 3-per-7d threshold; carry episode and sample-anchor +// metadata into the committed bundle." +// +// Three mandated regressions (sol scope ruling, msg 0001784468875582): +// R1: four hold_ball 429s in one 7-second episode → rawEventCount=4, +// episodeCount=1, does NOT alone trigger the three-episode threshold +// R2: three separated episodes still trigger +// R3: isolated A2A streak-4 block stays independently attributable +// +// Coalescing contract (sol ruling): +// - group key: guardId + threadId + catId — ALL three must be trusted +// non-empty values, else the event forms its own episode (no unknown-merge) +// - stable sort: timestamp asc, tie-break by per-event unique id (eventId — +// the raw-rejection coordinate; episodeId is derived, never interchanged) +// - adjacent gap ≤ EPISODE_GAP_MS (named constant, 60s, no per-guard config +// surface in V2) chains events into one episode +// --------------------------------------------------------------------------- + +/** The PR #41 burst: 4 hold_ball 429s spanning 7.044 seconds. */ +function verdictBurst(base = T) { + return [ + rawEvent({ timestamp: base, seq: 0 }), + rawEvent({ timestamp: base + 2300, seq: 1 }), + rawEvent({ timestamp: base + 4700, seq: 2 }), + rawEvent({ timestamp: base + 7044, seq: 3 }), + ]; +} + +function a2aBlockEvent(over = {}) { + return { + eventId: `evt-a2a-${over.timestamp ?? T}`, + kind: 'route_decision_block', + threadId: 'thread_2', + catId: 'cat_2', + guardId: 'a2a_pingpong_block', + ownerUserId: 'user_1', + timestamp: T, + correlationConfidence: 'window', + fromCatId: 'cat_2', + targetCatId: 'cat_3', + streakCount: 4, + ...over, + }; +} + +/** Fake event log backed by a fixed event array; used by Part 3 (snapshot/bundle). */ +function createFakeLogWithEvents(events) { + const filter = (opts) => + events.filter( + (e) => + (!opts.guardId || e.guardId === opts.guardId) && + (!opts.threadId || e.threadId === opts.threadId) && + (!opts.catId || e.catId === opts.catId) && + (!opts.ownerUserId || e.ownerUserId === opts.ownerUserId) && + e.timestamp >= opts.since && + e.timestamp < (opts.until ?? Number.POSITIVE_INFINITY), + ); + return { + queryWindow: mock.fn(async (opts) => filter(opts)), + queryWindowStrict: mock.fn(async (opts) => filter(opts)), + queryWindowComplete: mock.fn(async (opts) => ({ events: filter(opts), truncated: false })), + queryWindowStrictComplete: mock.fn(async (opts) => ({ events: filter(opts), truncated: false })), + countByGuard: mock.fn(async (guardId, since, until) => filter({ guardId, since, until }).length), + append: async () => {}, + }; +} + +// --------------------------------------------------------------------------- +// Part 1 — canonical coalescer (pure function) +// --------------------------------------------------------------------------- + +describe('coalesceGuardEpisodes — canonical coalescer', () => { + it('exports EPISODE_GAP_MS = 60s named constant (V2: no per-guard config surface)', () => { + assert.equal(EPISODE_GAP_MS, 60_000); + }); + + it('R1 core: PR #41 burst (4 events / 7.044s) coalesces into ONE episode preserving rawEventCount=4', () => { + const episodes = coalesceGuardEpisodes(verdictBurst()); + assert.equal(episodes.length, 1, 'burst must form exactly one episode'); + const ep = episodes[0]; + assert.equal(ep.rawEventCount, 4, 'rawEventCount preserved'); + assert.equal(ep.guardId, 'hold_ball_rate_limit'); + assert.equal(ep.startMs, T); + assert.equal(ep.endMs, T + 7044); + assert.ok(Array.isArray(ep.sampleAnchors) && ep.sampleAnchors.length > 0, 'episode carries sample anchors'); + assert.ok( + ep.sampleAnchors.every((a) => a.eventId && typeof a.timestamp === 'number'), + 'anchors carry eventId + timestamp for independent recheck (PR #41 provenance gap)', + ); + }); + + it('R2 core: three separated episodes (gap > 60s) stay distinct', () => { + const events = [ + rawEvent({ timestamp: T, seq: 0 }), + rawEvent({ timestamp: T + 120_000, seq: 1 }), + rawEvent({ timestamp: T + 240_000, seq: 2 }), + ]; + const episodes = coalesceGuardEpisodes(events); + assert.equal(episodes.length, 3, 'separated events form separate episodes'); + assert.ok(episodes.every((e) => e.rawEventCount === 1)); + }); + + it('chain semantics: adjacent gap ≤ 60s extends the episode even when total span > 60s', () => { + // 3 events at 0s / 50s / 100s — each adjacent gap is 50s (≤60s), total span 100s. + // Gap-based chaining (sol ruling) keeps them ONE episode; a fixed window would split. + const events = [ + rawEvent({ timestamp: T, seq: 0 }), + rawEvent({ timestamp: T + 50_000, seq: 1 }), + rawEvent({ timestamp: T + 100_000, seq: 2 }), + ]; + const episodes = coalesceGuardEpisodes(events); + assert.equal(episodes.length, 1, 'chained retries stay one episode'); + assert.equal(episodes[0].rawEventCount, 3); + }); + + it('boundary: gap exactly 60s merges; 60s+1ms splits', () => { + const merged = coalesceGuardEpisodes([ + rawEvent({ timestamp: T, seq: 0 }), + rawEvent({ timestamp: T + EPISODE_GAP_MS, seq: 1 }), + ]); + assert.equal(merged.length, 1, 'gap == EPISODE_GAP_MS merges'); + + const split = coalesceGuardEpisodes([ + rawEvent({ timestamp: T, seq: 0 }), + rawEvent({ timestamp: T + EPISODE_GAP_MS + 1, seq: 1 }), + ]); + assert.equal(split.length, 2, 'gap > EPISODE_GAP_MS splits'); + }); + + it('R3 core: mixed stream — A2A streak-4 block never merges into the hold_ball burst', () => { + const events = [...verdictBurst(), a2aBlockEvent({ timestamp: T + 3000 })]; + const episodes = coalesceGuardEpisodes(events); + assert.equal(episodes.length, 2, 'one hold_ball episode + one independent A2A episode'); + const holdBall = episodes.find((e) => e.guardId === 'hold_ball_rate_limit'); + const a2a = episodes.find((e) => e.guardId === 'a2a_pingpong_block'); + assert.equal(holdBall.rawEventCount, 4); + assert.equal(a2a.rawEventCount, 1, 'A2A block independently attributable'); + }); + + it('does not merge across threadId or catId even within gap', () => { + const events = [ + rawEvent({ timestamp: T, seq: 0 }), + rawEvent({ timestamp: T + 1000, seq: 1, threadId: 'thread_other' }), + rawEvent({ timestamp: T + 2000, seq: 2, catId: 'cat_other' }), + ]; + const episodes = coalesceGuardEpisodes(events); + assert.equal(episodes.length, 3, 'guardId+threadId+catId is the full group key'); + }); + + it('untrusted key (empty / unknown) → event forms its own episode, never merged', () => { + const events = [ + rawEvent({ timestamp: T, seq: 0, threadId: '' }), + rawEvent({ timestamp: T + 1000, seq: 1, threadId: '' }), + rawEvent({ timestamp: T + 2000, seq: 2, catId: 'unknown' }), + rawEvent({ timestamp: T + 3000, seq: 3, catId: 'unknown' }), + ]; + const episodes = coalesceGuardEpisodes(events); + assert.equal(episodes.length, 4, 'untrusted identity must not co-mingle into shared episodes'); + }); + + it('stable deterministic output: unordered input + same-timestamp ties produce identical episodes', () => { + const shuffled = [ + rawEvent({ timestamp: T + 4700, seq: 2 }), + rawEvent({ timestamp: T, seq: 0 }), + rawEvent({ timestamp: T + 7044, seq: 3 }), + rawEvent({ timestamp: T + 2300, seq: 1 }), + ]; + const a = coalesceGuardEpisodes(shuffled); + const b = coalesceGuardEpisodes([...shuffled].reverse()); + assert.deepEqual(a, b, 'coalescing must be input-order independent (replayable)'); + assert.equal(a.length, 1); + assert.ok(a[0].episodeId, 'episode has derived episodeId'); + assert.notEqual(a[0].episodeId, a[0].sampleAnchors[0].eventId, 'episodeId is derived, not a raw eventId'); + }); +}); + +// --------------------------------------------------------------------------- +// Part 2 — escalation accounting uses episodeCount (verdict R1/R2/R3) +// +// sol R7 P1-1: these tests now use the canonical ZSET-aware createFakeRedis +// from _guard-test-helpers.js (shared with threshold suite). Events are seeded +// into the ZSET so countEpisodesPagewise reads them via iterateWindow. +// guardRejectionLog RESTORED to deps (Fable ruling: restore EventLog dep). +// --------------------------------------------------------------------------- + +describe('checkGuardThreshold — episode-based 3-per-7d accounting', () => { + it('R1: four 429s in one 7s episode do NOT trigger (raw=4, episode=1)', async () => { + const burst = verdictBurst(); + const { redis, guardRejectionLog } = await createFakeEventSource(burst); + const triggerEval = mock.fn(async () => triggerSuccess()); + + // Check fires on the 4th (latest) event of the burst. + const result = await checkGuardThreshold(burst[3], { redis, guardRejectionLog, triggerEval }); + + assert.equal(result.rawEventCount, 4, 'rawEventCount preserved in result'); + assert.equal(result.episodeCount, 1, 'burst counts as one episode'); + assert.equal(result.thresholdMet, false, 'one episode < 3 → threshold NOT met'); + assert.equal(result.escalated, false); + assert.equal(triggerEval.mock.callCount(), 0, 'burst alone must NOT invoke eval cat'); + }); + + it('R2: three separated episodes still trigger', async () => { + const events = [ + rawEvent({ timestamp: T, seq: 0 }), + rawEvent({ timestamp: T + 3_600_000, seq: 1 }), + rawEvent({ timestamp: T + 7_200_000, seq: 2 }), + ]; + const { redis, guardRejectionLog } = await createFakeEventSource(events); + const triggerEval = mock.fn(async () => triggerSuccess()); + + const result = await checkGuardThreshold(events[2], { redis, guardRejectionLog, triggerEval }); + + assert.equal(result.episodeCount, 3); + assert.equal(result.thresholdMet, true, 'three separated episodes meet the threshold'); + assert.equal(result.escalated, true); + assert.equal(triggerEval.mock.callCount(), 1); + }); + + it('R2 variant: episodes from different cats count as distinct incidents for the same guard', async () => { + const events = [ + rawEvent({ timestamp: T, seq: 0, catId: 'cat_a', threadId: 'th_a' }), + rawEvent({ timestamp: T + 5000, seq: 1, catId: 'cat_b', threadId: 'th_b' }), + rawEvent({ timestamp: T + 9000, seq: 2, catId: 'cat_c', threadId: 'th_c' }), + ]; + const { redis, guardRejectionLog } = await createFakeEventSource(events); + const triggerEval = mock.fn(async () => triggerSuccess()); + + const result = await checkGuardThreshold(events[2], { redis, guardRejectionLog, triggerEval }); + + assert.equal(result.episodeCount, 3, 'distributed incidents are real distinct episodes'); + assert.equal(result.escalated, true); + }); + + it('R3: isolated A2A streak-4 block is independently attributable and does not trigger alone', async () => { + // Window contains the hold_ball burst AND one isolated A2A block. + // Both are seeded into the same ZSET — pagewise guardId filter separates them. + const all = [...verdictBurst(), a2aBlockEvent({ timestamp: T + 3000 })]; + const { redis, guardRejectionLog } = await createFakeEventSource(all); + const triggerEval = mock.fn(async () => triggerSuccess()); + + // Escalation check for the A2A guard sees ONLY its own guard's events. + const a2aResult = await checkGuardThreshold(all[4], { redis, guardRejectionLog, triggerEval }); + assert.equal(a2aResult.guardId, 'a2a_pingpong_block'); + assert.equal(a2aResult.rawEventCount, 1, 'A2A accounting unaffected by hold_ball burst'); + assert.equal(a2aResult.episodeCount, 1); + assert.equal(a2aResult.escalated, false, 'single A2A episode must not trigger'); + + // And the hold_ball check in the same window still sees episode=1 (R1). + const hbResult = await checkGuardThreshold(all[3], { redis, guardRejectionLog, triggerEval }); + assert.equal(hbResult.episodeCount, 1); + assert.equal(hbResult.escalated, false); + assert.equal(triggerEval.mock.callCount(), 0, 'neither guard triggers from this window'); + }); + + it('claim value records episodeCount alongside raw count', async () => { + const events = [ + rawEvent({ timestamp: T, seq: 0 }), + rawEvent({ timestamp: T + 1000, seq: 1 }), + rawEvent({ timestamp: T + 200_000, seq: 2 }), + rawEvent({ timestamp: T + 400_000, seq: 3 }), + ]; + const { redis, guardRejectionLog } = await createFakeEventSource(events); + const triggerEval = mock.fn(async () => triggerSuccess()); + + const result = await checkGuardThreshold(events[3], { redis, guardRejectionLog, triggerEval }); + assert.equal(result.rawEventCount, 4); + assert.equal(result.episodeCount, 3, '2-event burst + 2 separated = 3 episodes'); + assert.equal(result.escalated, true); + + const stored = JSON.parse(redis._store.get('guard-rejection:escalated:user_1:hold_ball_rate_limit')); + assert.equal(stored.episodeCount, 3, 'dedup claim carries episodeCount (incident semantics)'); + }); +}); + +// --------------------------------------------------------------------------- +// Part 3 — snapshot & committed bundle carry episode + anchor metadata +// (PR #41: "bundle itself cannot independently recheck the 7.044s claim") +// --------------------------------------------------------------------------- + +describe('snapshot provider — per-guard episode metadata', () => { + it('byGuard aggregates carry rawEventCount + episodeCount + episodes with anchors', async () => { + // Provider windows on Date.now() — place the burst just inside the window. + const base = Date.now() - 60_000; + const all = [...verdictBurst(base), a2aBlockEvent({ timestamp: base + 3000 })]; + const log = createFakeLogWithEvents(all); + const root = mkdtempSync(join(tmpdir(), 'f257-episode-')); + + const result = await produceHarnessLedgerRunSnapshot({ + guardRejectionLog: log, + harnessFeedbackRoot: root, + ownerUserId: 'user_1', + }); + + const hb = result.snapshot.byGuard.hold_ball_rate_limit; + assert.equal(hb.count, 4, 'raw count preserved'); + assert.equal(hb.episodeCount, 1, 'burst = one episode'); + assert.ok(Array.isArray(hb.episodes) && hb.episodes.length === 1, 'episode metadata present'); + assert.equal(hb.episodes[0].rawEventCount, 4); + assert.ok(hb.episodes[0].sampleAnchors.length > 0, 'episode anchors present (timestamps for recheck)'); + + const a2a = result.snapshot.byGuard.a2a_pingpong_block; + assert.equal(a2a.count, 1); + assert.equal(a2a.episodeCount, 1); + + // Persisted file matches in-memory snapshot (KD-17 single source). + const persisted = JSON.parse(readFileSync(result.storagePath, 'utf8')); + assert.deepEqual(persisted.byGuard, result.snapshot.byGuard); + }); +}); + +describe('generator adapter — committed bundle provenance (PR #41 gap)', () => { + it('bundle snapshot.json carries sampleAnchors + per-guard raw/episode counts + episode metadata', async () => { + const root = mkdtempSync(join(tmpdir(), 'f257-bundle-')); + const evalRunId = 'hlr-1700000000000-abcd1234'; + const windowStartMs = T - 1000; + const windowEndMs = T + 100_000; + + // Stored run snapshot in the NEW schema (provider output shape). + const storedSnapshot = { + evalRunId, + producedAt: new Date(T).toISOString(), + ownerUserId: 'user_1', + window: { startMs: windowStartMs, endMs: windowEndMs, durationHours: 168 }, + totalEvents: 4, + byKind: { http_rate_limit: 4 }, + byGuard: { + hold_ball_rate_limit: { + count: 4, + kinds: ['http_rate_limit'], + episodeCount: 1, + episodes: [ + { + episodeId: 'ep-test0000000001', + startMs: T, + endMs: T + 7044, + rawEventCount: 4, + sampleAnchors: [ + { eventId: 'evt-1', kind: 'http_rate_limit', guardId: 'hold_ball_rate_limit', timestamp: T }, + { eventId: 'evt-4', kind: 'http_rate_limit', guardId: 'hold_ball_rate_limit', timestamp: T + 7044 }, + ], + }, + ], + }, + }, + sampleAnchors: [{ eventId: 'evt-1', kind: 'http_rate_limit', guardId: 'hold_ball_rate_limit', timestamp: T }], + howCounted: 'zset-window-scan', + }; + mkdirSync(join(root, 'run-snapshots'), { recursive: true }); + writeFileSync(join(root, 'run-snapshots', `${evalRunId}.json`), JSON.stringify(storedSnapshot)); + + const generate = createHarnessLedgerGeneratorAdapter(); + const { bundleDir } = await generate( + { id: 'test-verdict-episode-1', verdict: 'fix' }, + { kind: 'prompt-segments', windowStartMs, windowEndMs, evalRunId }, + { harnessFeedbackRoot: root, liveHarnessFeedbackRoot: root, ownerUserId: 'user_1' }, + ); + + const bundle = JSON.parse(readFileSync(join(bundleDir, 'snapshot.json'), 'utf8')); + + assert.ok(Array.isArray(bundle.sampleAnchors), 'bundle must carry sample anchors (PR #41: anchors were dropped)'); + assert.equal(bundle.sampleAnchors.length, 1); + assert.equal(bundle.sampleAnchors[0].eventId, 'evt-1'); + assert.ok( + typeof bundle.sampleAnchors[0].timestamp === 'number', + 'anchor timestamps enable independent burst recheck', + ); + + const hb = bundle.byGuardEpisodes.hold_ball_rate_limit; + assert.equal(hb.rawEventCount, 4, 'bundle preserves rawEventCount'); + assert.equal(hb.episodeCount, 1, 'bundle carries episodeCount (distinct incidents)'); + assert.equal(hb.episodes[0].rawEventCount, 4, 'bundle carries episode metadata'); + assert.equal(hb.episodes[0].endMs - hb.episodes[0].startMs, 7044, 'episode span independently recheckable'); + + // Backward-compat: legacy count-only map remains for existing consumers. + assert.equal(bundle.byGuard.hold_ball_rate_limit, 4); + }); +}); + +// --------------------------------------------------------------------------- +// sol R10 P1-1: generator adapter owner-scope validation (three-state) +// --------------------------------------------------------------------------- + +describe('generator adapter — owner-scope validation (sol R10 P1-1)', () => { + function makeStoredSnapshot(overrides = {}) { + const evalRunId = 'hlr-1700000000000-abcd1234'; + const windowStartMs = T - 1000; + const windowEndMs = T + 100_000; + return { + snapshot: { + evalRunId, + producedAt: new Date(T).toISOString(), + ownerUserId: 'user_1', + window: { startMs: windowStartMs, endMs: windowEndMs, durationHours: 168 }, + totalEvents: 1, + byKind: { http_rate_limit: 1 }, + byGuard: { + hold_ball_rate_limit: { + count: 1, + kinds: ['http_rate_limit'], + episodeCount: 1, + episodes: [], + }, + }, + sampleAnchors: [], + howCounted: 'zset-window-scan', + truncated: false, + ...overrides, + }, + evalRunId, + windowStartMs, + windowEndMs, + }; + } + + function writeSnapshot(root, snap) { + mkdirSync(join(root, 'run-snapshots'), { recursive: true }); + writeFileSync(join(root, 'run-snapshots', `${snap.evalRunId}.json`), JSON.stringify(snap.snapshot)); + } + + it('rejects when deps.ownerUserId is missing (undefined)', async () => { + const root = mkdtempSync(join(tmpdir(), 'f257-owner-')); + const snap = makeStoredSnapshot(); + writeSnapshot(root, snap); + const generate = createHarnessLedgerGeneratorAdapter(); + + await assert.rejects( + () => + generate( + { id: 'v-no-owner' }, + { + kind: 'prompt-segments', + windowStartMs: snap.windowStartMs, + windowEndMs: snap.windowEndMs, + evalRunId: snap.evalRunId, + }, + { harnessFeedbackRoot: root, liveHarnessFeedbackRoot: root }, + ), + (err) => { + assert.ok(err.message.includes('owner_missing'), `expected owner_missing, got: ${err.message}`); + return true; + }, + ); + }); + + it('rejects when deps.ownerUserId is empty string', async () => { + const root = mkdtempSync(join(tmpdir(), 'f257-owner-')); + const snap = makeStoredSnapshot(); + writeSnapshot(root, snap); + const generate = createHarnessLedgerGeneratorAdapter(); + + await assert.rejects( + () => + generate( + { id: 'v-empty-owner' }, + { + kind: 'prompt-segments', + windowStartMs: snap.windowStartMs, + windowEndMs: snap.windowEndMs, + evalRunId: snap.evalRunId, + }, + { harnessFeedbackRoot: root, liveHarnessFeedbackRoot: root, ownerUserId: '' }, + ), + (err) => { + assert.ok(err.message.includes('owner_missing'), `expected owner_missing, got: ${err.message}`); + return true; + }, + ); + }); + + it('rejects when stored snapshot lacks ownerUserId', async () => { + const root = mkdtempSync(join(tmpdir(), 'f257-owner-')); + const snap = makeStoredSnapshot(); + // Remove ownerUserId from persisted snapshot (legacy format) + delete snap.snapshot.ownerUserId; + writeSnapshot(root, snap); + const generate = createHarnessLedgerGeneratorAdapter(); + + await assert.rejects( + () => + generate( + { id: 'v-legacy-snap' }, + { + kind: 'prompt-segments', + windowStartMs: snap.windowStartMs, + windowEndMs: snap.windowEndMs, + evalRunId: snap.evalRunId, + }, + { harnessFeedbackRoot: root, liveHarnessFeedbackRoot: root, ownerUserId: 'user_1' }, + ), + (err) => { + assert.ok( + err.message.includes('snapshot_owner_missing'), + `expected snapshot_owner_missing, got: ${err.message}`, + ); + return true; + }, + ); + }); + + it('rejects on owner mismatch (cross-owner artifact forbidden)', async () => { + const root = mkdtempSync(join(tmpdir(), 'f257-owner-')); + const snap = makeStoredSnapshot({ ownerUserId: 'user_1' }); + writeSnapshot(root, snap); + const generate = createHarnessLedgerGeneratorAdapter(); + + await assert.rejects( + () => + generate( + { id: 'v-mismatch' }, + { + kind: 'prompt-segments', + windowStartMs: snap.windowStartMs, + windowEndMs: snap.windowEndMs, + evalRunId: snap.evalRunId, + }, + { harnessFeedbackRoot: root, liveHarnessFeedbackRoot: root, ownerUserId: 'user_2' }, + ), + (err) => { + assert.ok(err.message.includes('owner_mismatch'), `expected owner_mismatch, got: ${err.message}`); + // sol R10 P1-1: error must NOT leak actual owner values + assert.ok(!err.message.includes('user_1'), 'error must NOT leak stored owner value'); + assert.ok(!err.message.includes('user_2'), 'error must NOT leak deps owner value'); + return true; + }, + ); + }); + + it('succeeds when deps.ownerUserId matches snapshot.ownerUserId', async () => { + const root = mkdtempSync(join(tmpdir(), 'f257-owner-')); + const snap = makeStoredSnapshot({ ownerUserId: 'matching-owner' }); + writeSnapshot(root, snap); + const generate = createHarnessLedgerGeneratorAdapter(); + + const result = await generate( + { id: 'v-match' }, + { + kind: 'prompt-segments', + windowStartMs: snap.windowStartMs, + windowEndMs: snap.windowEndMs, + evalRunId: snap.evalRunId, + }, + { harnessFeedbackRoot: root, liveHarnessFeedbackRoot: root, ownerUserId: 'matching-owner' }, + ); + + assert.ok(result.verdictPath, 'should produce verdict'); + assert.ok(result.bundleDir, 'should produce bundle'); + }); + + it('mismatch produces zero artifacts (fail-closed)', async () => { + const root = mkdtempSync(join(tmpdir(), 'f257-owner-')); + const snap = makeStoredSnapshot({ ownerUserId: 'owner-a' }); + writeSnapshot(root, snap); + const generate = createHarnessLedgerGeneratorAdapter(); + + try { + await generate( + { id: 'v-no-artifacts' }, + { + kind: 'prompt-segments', + windowStartMs: snap.windowStartMs, + windowEndMs: snap.windowEndMs, + evalRunId: snap.evalRunId, + }, + { harnessFeedbackRoot: root, liveHarnessFeedbackRoot: root, ownerUserId: 'owner-b' }, + ); + assert.fail('should have thrown'); + } catch { + // Verify no artifacts were written + const { existsSync } = await import('node:fs'); + assert.equal(existsSync(join(root, 'verdicts', 'v-no-artifacts.md')), false, 'no verdict file on mismatch'); + assert.equal(existsSync(join(root, 'bundles', 'v-no-artifacts')), false, 'no bundle dir on mismatch'); + } + }); +}); + +// --------------------------------------------------------------------------- +// sol R10 supplementary: snapshot provider rejects empty ownerUserId at runtime +// --------------------------------------------------------------------------- + +describe('snapshot provider — ownerUserId runtime validation (sol R10)', () => { + it('rejects empty string ownerUserId', async () => { + const log = createFakeLogWithEvents([]); + const root = mkdtempSync(join(tmpdir(), 'f257-snap-owner-')); + + await assert.rejects( + () => + produceHarnessLedgerRunSnapshot({ + guardRejectionLog: log, + harnessFeedbackRoot: root, + ownerUserId: '', + }), + (err) => { + assert.ok(err.message.includes('owner_required'), `expected owner_required, got: ${err.message}`); + return true; + }, + ); + }); +}); + +// --------------------------------------------------------------------------- +// sol R10 P2-2 #1: mixed-owner snapshot isolation +// --------------------------------------------------------------------------- + +describe('snapshot provider — mixed-owner isolation via real EventLog (sol R11 P2-1)', () => { + it('snapshot for owner A contains only A events when both A and B exist', async () => { + // sol R11 P2-1: use real GuardRejectionEventLog (not createFakeLogWithEvents) + // so the production iterateWindow owner filter at line 316 is exercised. + // A regression in the ZSET-based filter would cause this test to fail. + const base = Date.now() - 60_000; + const eventsA = [ + rawEvent({ timestamp: base, seq: 0, ownerUserId: 'owner-a', guardId: 'guard-a-only' }), + rawEvent({ timestamp: base + 1000, seq: 1, ownerUserId: 'owner-a' }), + ]; + const eventsB = [ + rawEvent({ timestamp: base + 2000, seq: 2, ownerUserId: 'owner-b', guardId: 'guard-b-only' }), + rawEvent({ timestamp: base + 3000, seq: 3, ownerUserId: 'owner-b', guardId: 'guard-b-only' }), + rawEvent({ timestamp: base + 4000, seq: 4, ownerUserId: 'owner-b', guardId: 'guard-b-only' }), + ]; + const { guardRejectionLog } = await createFakeEventSource([...eventsA, ...eventsB]); + const root = mkdtempSync(join(tmpdir(), 'f257-mixed-')); + + const resultA = await produceHarnessLedgerRunSnapshot({ + guardRejectionLog, + harnessFeedbackRoot: root, + ownerUserId: 'owner-a', + }); + + assert.equal(resultA.snapshot.totalEvents, 2, 'owner-a snapshot must contain only 2 events'); + assert.equal(resultA.snapshot.ownerUserId, 'owner-a', 'ownerUserId persisted in snapshot'); + + // rawEvents must all belong to owner-a (transient in-process, not persisted) + assert.ok( + resultA.rawEvents.every((e) => e.guardId === 'guard-a-only' || e.guardId === 'hold_ball_rate_limit'), + 'rawEvents must only contain A anchors/guards', + ); + + // B-unique guard must NOT leak into A snapshot + assert.equal(resultA.snapshot.byGuard['guard-b-only'], undefined, 'B-only guard must not appear in A snapshot'); + + // Persisted snapshot matches in-memory + const { readFileSync } = await import('node:fs'); + const persisted = JSON.parse(readFileSync(resultA.storagePath, 'utf8')); + assert.equal(persisted.totalEvents, 2, 'persisted snapshot also has 2 events'); + assert.equal(persisted.ownerUserId, 'owner-a', 'persisted owner matches'); + }); +}); diff --git a/packages/api/test/harness-eval/guard-rejection-r3-regression.test.js b/packages/api/test/harness-eval/guard-rejection-r3-regression.test.js new file mode 100644 index 0000000000..ae1827c361 --- /dev/null +++ b/packages/api/test/harness-eval/guard-rejection-r3-regression.test.js @@ -0,0 +1,225 @@ +/** + * F257 V2 R3 regression tests — sol verdict 1×P1 + 5×P2. + * + * P2-2: isRegisteredLedgerId reverse whitelist (prototype-safe) + * P2-4④: threshold truncated → conservative-true (pagewise) + * P2-4⑤: bundle truncated → confidence 'low' + * + * [opus/claude-opus-4-6🐾] + */ + +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it, mock } from 'node:test'; + +import { + GUARD_LEDGER_IDS, + isRegisteredGuardId, + isRegisteredLedgerId, +} from '../../dist/infrastructure/harness-eval/guard-ledger-registry.js'; +import { checkGuardThreshold } from '../../dist/infrastructure/harness-eval/guard-threshold-escalation.js'; +import { createHarnessLedgerGeneratorAdapter } from '../../dist/infrastructure/harness-eval/publish-verdict/harness-ledger-generator-adapter.js'; +import { createFakeEventSource, createFakeRedis, rawEvent, T, triggerSuccess } from './_guard-test-helpers.js'; + +// --------------------------------------------------------------------------- +// P2-2: isRegisteredLedgerId — reverse whitelist +// --------------------------------------------------------------------------- + +describe('P2-2: isRegisteredLedgerId reverse whitelist', () => { + it('returns true for all registered ledgerIds', () => { + for (const ledgerId of Object.values(GUARD_LEDGER_IDS)) { + assert.equal(isRegisteredLedgerId(ledgerId), true, `${ledgerId} should be registered`); + } + }); + + it('returns false for unregistered strings', () => { + assert.equal(isRegisteredLedgerId('evil/fake-pot'), false); + assert.equal(isRegisteredLedgerId('mcp/nonexistent'), false); + }); + + it('returns false for prototype keys (prototype-safe)', () => { + assert.equal(isRegisteredLedgerId('toString'), false); + assert.equal(isRegisteredLedgerId('constructor'), false); + assert.equal(isRegisteredLedgerId('__proto__'), false); + assert.equal(isRegisteredLedgerId('hasOwnProperty'), false); + }); + + it('isRegisteredGuardId also rejects prototype keys (pre-existing P1-3)', () => { + assert.equal(isRegisteredGuardId('toString'), false); + assert.equal(isRegisteredGuardId('constructor'), false); + assert.equal(isRegisteredGuardId('__proto__'), false); + }); +}); + +// --------------------------------------------------------------------------- +// P2-4④: threshold truncated → conservative-true +// --------------------------------------------------------------------------- + +describe('P2-4④: truncated window → conservative-true threshold (pagewise)', () => { + it('meetsThreshold=true when hard cap hit even if episode count < threshold', async () => { + // Seed 10,001 events forming 1 episode (same group, 1ms gaps) — exceeds + // HARD_CAP so pagewise counter returns earlyStopReason='hard_cap' + const events = Array.from({ length: 10_001 }, (_, i) => + rawEvent({ timestamp: T + i, seq: i, eventId: `cap-evt-${i}` }), + ); + const { redis, guardRejectionLog } = await createFakeEventSource(events); + const triggerEval = mock.fn(async () => triggerSuccess()); + + const result = await checkGuardThreshold(rawEvent({ timestamp: T + 10_001 }), { + redis, + guardRejectionLog, + triggerEval, + }); + + assert.equal(result.episodeCount, 1, 'all events chain into 1 episode (1ms gaps)'); + assert.equal(result.thresholdMet, true, 'hard cap → conservative-true regardless of count'); + assert.equal(result.truncated, true, 'truncated flag propagated'); + assert.equal(result.escalated, true, 'should escalate on conservative-true'); + assert.equal(triggerEval.mock.callCount(), 1); + }); + + it('pagewise stops Redis I/O after threshold met (early-stop)', async () => { + // 5 separated events → 5 episodes. Threshold is 3. + // Pagewise should stop after finding 3rd episode, NOT fetch remaining pages. + const events = Array.from({ length: 5 }, (_, i) => + rawEvent({ timestamp: T + i * 120_000, seq: i, eventId: `early-${i}` }), + ); + const { redis, guardRejectionLog } = await createFakeEventSource(events); + const triggerEval = mock.fn(async () => triggerSuccess()); + + const result = await checkGuardThreshold(rawEvent({ timestamp: T + 700_000 }), { + redis, + guardRejectionLog, + triggerEval, + }); + + assert.equal(result.episodeCount, 3, 'early-stopped at k=3 (not actual 5)'); + assert.equal(result.episodeCountIsLowerBound, true, 'explicitly marked as lower bound'); + assert.equal(result.thresholdMet, true); + assert.equal(result.pagesFetched, 1, 'all 5 events fit in 1 page — no excess fetching'); + }); + + it('distinct-key episodes early-stop without scanning full window (sol R6 P2-1)', async () => { + // 1001 events, each with a DIFFERENT threadId → 1001 distinct episodes. + // Threshold is 3. Pagewise should stop after 3rd distinct key, NOT scan all 1001. + // This proves the lower-bound counting (closed + openRunTs.size >= k). + const events = Array.from({ length: 1001 }, (_, i) => + rawEvent({ timestamp: T + i * 120_000, seq: i, eventId: `dk-${i}`, threadId: `thread_${i}` }), + ); + const { redis, guardRejectionLog } = await createFakeEventSource(events); + const triggerEval = mock.fn(async () => triggerSuccess()); + + const result = await checkGuardThreshold(rawEvent({ timestamp: T + 200_000_000 }), { + redis, + guardRejectionLog, + triggerEval, + }); + + assert.equal(result.episodeCount, 3, 'early-stopped at k=3'); + assert.equal(result.episodeCountIsLowerBound, true, 'marked as lower bound'); + assert.equal(result.rawEventCountIsLowerBound, true, 'raw count is also a lower bound'); + assert.equal(result.thresholdMet, true); + assert.equal(result.pagesFetched, 1, 'stopped within first page — no excess I/O'); + assert.ok(result.rawEventCount <= 4, 'scanned ≤ 4 events before stopping (3 needed + at most 1 extra)'); + }); + + it('exact count when episodes < threshold (no lower bound)', async () => { + // 2 separated events → 2 episodes < threshold 3 + const events = [rawEvent({ timestamp: T, seq: 0 }), rawEvent({ timestamp: T + 120_000, seq: 1 })]; + const { redis, guardRejectionLog } = await createFakeEventSource(events); + const triggerEval = mock.fn(async () => triggerSuccess()); + + const result = await checkGuardThreshold(rawEvent({ timestamp: T + 240_000 }), { + redis, + guardRejectionLog, + triggerEval, + }); + + assert.equal(result.episodeCount, 2, 'exact count reported'); + assert.equal(result.episodeCountIsLowerBound, undefined, 'NOT marked as lower bound'); + assert.equal(result.thresholdMet, false); + assert.equal(result.escalated, false); + }); +}); + +// --------------------------------------------------------------------------- +// P2-4⑤: bundle truncated → confidence 'low' +// --------------------------------------------------------------------------- + +describe('P2-4⑤: bundle truncated → confidence low', () => { + it('committed bundle snapshot has confidence=low when truncated=true', async () => { + const root = mkdtempSync(join(tmpdir(), 'f257-r3-trunc-')); + const evalRunId = 'hlr-1700000000000-abcd1234'; + const windowStartMs = T - 1000; + const windowEndMs = T + 100_000; + + const storedSnapshot = { + evalRunId, + producedAt: new Date(T).toISOString(), + ownerUserId: 'user_1', + window: { startMs: windowStartMs, endMs: windowEndMs, durationHours: 168 }, + totalEvents: 10000, + byKind: { http_rate_limit: 10000 }, + byGuard: { + hold_ball_rate_limit: { + count: 10000, + kinds: ['http_rate_limit'], + episodeCount: 50, + episodes: [], + }, + }, + sampleAnchors: [], + howCounted: 'zset-window-scan', + truncated: true, + }; + mkdirSync(join(root, 'run-snapshots'), { recursive: true }); + writeFileSync(join(root, 'run-snapshots', `${evalRunId}.json`), JSON.stringify(storedSnapshot)); + + const generate = createHarnessLedgerGeneratorAdapter(); + const { bundleDir } = await generate( + { id: 'test-r3-truncated-1', verdict: 'fix' }, + { kind: 'prompt-segments', windowStartMs, windowEndMs, evalRunId }, + { harnessFeedbackRoot: root, liveHarnessFeedbackRoot: root, ownerUserId: 'user_1' }, + ); + + const bundle = JSON.parse(readFileSync(join(bundleDir, 'snapshot.json'), 'utf8')); + + assert.equal(bundle.truncated, true, 'truncated must survive into committed bundle'); + assert.equal(bundle.components[0].confidence, 'low', 'truncated → confidence low'); + }); + + it('non-truncated bundle has confidence=medium when events exist', async () => { + const root = mkdtempSync(join(tmpdir(), 'f257-r3-normal-')); + const evalRunId = 'hlr-1700000000001-abcd1234'; + const windowStartMs = T - 1000; + const windowEndMs = T + 100_000; + + const storedSnapshot = { + evalRunId, + producedAt: new Date(T).toISOString(), + ownerUserId: 'user_1', + window: { startMs: windowStartMs, endMs: windowEndMs, durationHours: 168 }, + totalEvents: 5, + byKind: { http_rate_limit: 5 }, + byGuard: { hold_ball_rate_limit: { count: 5, kinds: ['http_rate_limit'], episodeCount: 3, episodes: [] } }, + sampleAnchors: [], + howCounted: 'zset-window-scan', + truncated: false, + }; + mkdirSync(join(root, 'run-snapshots'), { recursive: true }); + writeFileSync(join(root, 'run-snapshots', `${evalRunId}.json`), JSON.stringify(storedSnapshot)); + + const generate = createHarnessLedgerGeneratorAdapter(); + const { bundleDir } = await generate( + { id: 'test-r3-normal-1', verdict: 'fix' }, + { kind: 'prompt-segments', windowStartMs, windowEndMs, evalRunId }, + { harnessFeedbackRoot: root, liveHarnessFeedbackRoot: root, ownerUserId: 'user_1' }, + ); + + const bundle = JSON.parse(readFileSync(join(bundleDir, 'snapshot.json'), 'utf8')); + assert.equal(bundle.truncated, false); + assert.equal(bundle.components[0].confidence, 'medium'); + }); +}); diff --git a/packages/api/test/harness-eval/guard-rejection-r3-routes.test.js b/packages/api/test/harness-eval/guard-rejection-r3-routes.test.js new file mode 100644 index 0000000000..669a3acbbd --- /dev/null +++ b/packages/api/test/harness-eval/guard-rejection-r3-routes.test.js @@ -0,0 +1,514 @@ +/** + * F257 V2 R3 route-level regression tests. + * + * P2-3: stats SCARD error → { available: false } + * P2-4①: owner dual-tenant GET isolation + * P2-4②: resolver throw → 202 + threadId=unknown + * P2-4③: >cap pagination (10,001 → 10,000 + truncated) + * P2-5: skip behavior (server-side skip events queryable + invalid kind rejected) + * + * [opus/claude-opus-4-6🐾] + */ + +import assert from 'node:assert/strict'; +import { describe, it, mock } from 'node:test'; +import Fastify from 'fastify'; + +// --------------------------------------------------------------------------- +// Helpers — shared app builder for callback-guard-rejection-routes +// --------------------------------------------------------------------------- + +const T = 1700000000000; + +let registry; +let threadStore; + +async function setup() { + const { InvocationRegistry } = await import( + '../../dist/domains/cats/services/agents/invocation/InvocationRegistry.js' + ); + const { ThreadStore } = await import('../../dist/domains/cats/services/stores/ports/ThreadStore.js'); + registry = new InvocationRegistry(); + threadStore = new ThreadStore(); +} + +function makeFakeLog(events = []) { + const appended = []; + return { + append: mock.fn(async (event) => { + appended.push(event); + }), + async queryWindowComplete(opts) { + return this.queryWindowStrictComplete(opts); + }, + async queryWindowStrictComplete(opts) { + const all = [...events, ...appended]; + const filtered = all.filter( + (e) => + (!opts.ledgerId || e.ledgerId === opts.ledgerId) && + (!opts.ownerUserId || e.ownerUserId === opts.ownerUserId) && + e.timestamp >= opts.since && + e.timestamp < (opts.until ?? Number.POSITIVE_INFINITY), + ); + return { events: filtered, truncated: false }; + }, + _appended: appended, + }; +} + +async function createApp(guardRejectionLog, extra = {}) { + const { callbacksRoutes } = await import('../../dist/routes/callbacks.js'); + const app = Fastify(); + await app.register(callbacksRoutes, { + registry, + messageStore: { + async getMessagesForThread() { + return []; + }, + }, + socketManager: { + broadcastAgentMessage() {}, + getMessages() { + return []; + }, + }, + threadStore, + evidenceStore: { + async store() {}, + async search() { + return []; + }, + }, + markerQueue: { enqueue() {} }, + reflectionService: { async run() {} }, + holdBallDeps: { + registry, + taskRunner: { registerDynamic() {}, unregister() {} }, + templateRegistry: { get() {} }, + dynamicTaskStore: { insert() {}, getAll: () => [], remove: () => true }, + messageStore: { async append() {} }, + socketManager: { broadcastToRoom() {} }, + guardRejectionLog, + }, + ...extra, + }); + return app; +} + +/** + * Directly register guard-rejection routes (bypassing callbacksRoutes) + * to inject deps like ledgerStats without going through Redis construction. + * Supports BOTH invocation and agent-key principal paths (sol R4 P2-4①). + */ +async function createDirectRouteApp(deps) { + const { registerCallbackGuardRejectionRoutes } = await import('../../dist/routes/callback-guard-rejection-routes.js'); + const app = Fastify(); + // Wire callback auth prehandler — supports invocation + agent-key principals + app.addHook('preHandler', async (request) => { + // Path 1: invocation principal (x-invocation-id + x-callback-token) + const invId = request.headers['x-invocation-id']; + const token = request.headers['x-callback-token']; + if (invId && token) { + const result = await registry.verify(invId, token); + if (result.ok) { + request.callbackPrincipal = { + kind: 'invocation', + userId: result.record.userId, + catId: result.record.catId, + threadId: result.record.threadId, + invocationId: invId, + }; + return; + } + } + // Path 2: agent-key principal (x-test-agent-key — test-only header) + const agentKeyHeader = request.headers['x-test-agent-key']; + if (agentKeyHeader) { + const record = JSON.parse(agentKeyHeader); + request.callbackPrincipal = { + kind: 'agent_key', + agentKeyId: record.agentKeyId ?? 'ak-test', + userId: record.userId, + catId: record.catId, + scope: record.scope ?? 'full', + }; + } + }); + registerCallbackGuardRejectionRoutes(app, deps); + return app; +} + +// --------------------------------------------------------------------------- +// P2-4②: resolver throw → 202 + threadId=unknown +// sol R4: must use AGENT-KEY principal (not invocation) to exercise the +// resolver branch. Production calls threadStore.get (not getById). Must +// assert threadId='unknown' in the appended event. +// --------------------------------------------------------------------------- + +describe('P2-4②: agent-key resolver throw → 202 + threadId=unknown', async () => { + await setup(); + + it('agent-key POST with resolver throw → 202, event.threadId=unknown', async () => { + const log = makeFakeLog(); + // threadStore.get is what resolveScopedThreadId calls (not getById) + const throwingThreadStore = { + async get() { + throw new Error('READONLY: Redis failover'); + }, + async list() { + throw new Error('READONLY: Redis failover'); + }, + }; + const app = await createDirectRouteApp({ + guardRejectionLog: log, + threadStore: throwingThreadStore, + }); + + const response = await app.inject({ + method: 'POST', + url: '/api/callbacks/guard-rejections', + headers: { + 'x-test-agent-key': JSON.stringify({ userId: 'user-ak-1', catId: 'codex' }), + }, + payload: { + kind: 'http_policy_reject', + guardId: 'cross_post_routing_credentials', + sourceTool: 'cross_post_message', + normalizedReason: 'resolver_test', + threadId: 'thread_that_will_fail_resolve', + }, + }); + + assert.equal(response.statusCode, 202, 'resolver throw must NOT 500 the ingest'); + assert.equal(response.json().accepted, true); + assert.equal(log._appended.length, 1, 'event logged despite resolver failure'); + // Critical assertion: threadId degrades to 'unknown' (coalescer untrusted-key isolation) + assert.equal(log._appended[0].threadId, 'unknown', 'failed resolver → threadId=unknown'); + assert.equal(log._appended[0].correlationConfidence, 'window', 'agent-key → window confidence'); + assert.equal(log._appended[0].catId, 'codex', 'catId from principal, not payload'); + assert.equal(log._appended[0].userId || log._appended[0].ownerUserId, 'user-ak-1', 'userId from principal'); + }); +}); + +// --------------------------------------------------------------------------- +// P2-4①: owner dual-tenant GET isolation (direct route — correct sinceMs) +// --------------------------------------------------------------------------- + +describe('P2-4①: owner dual-tenant GET isolation', async () => { + await setup(); + + it('GET returns only events for the authenticated owner', async () => { + const ownerAEvents = [ + { + eventId: 'e-a1', + ledgerId: 'mcp/hold-ball-rate-limit', + ownerUserId: 'owner-A', + threadId: 't1', + catId: 'c1', + guardId: 'hold_ball_rate_limit', + kind: 'http_rate_limit', + timestamp: T, + correlationConfidence: 'window', + }, + { + eventId: 'e-a2', + ledgerId: 'mcp/hold-ball-rate-limit', + ownerUserId: 'owner-A', + threadId: 't1', + catId: 'c1', + guardId: 'hold_ball_rate_limit', + kind: 'http_rate_limit', + timestamp: T + 1000, + correlationConfidence: 'window', + }, + ]; + const ownerBEvents = [ + { + eventId: 'e-b1', + ledgerId: 'mcp/hold-ball-rate-limit', + ownerUserId: 'owner-B', + threadId: 't2', + catId: 'c2', + guardId: 'hold_ball_rate_limit', + kind: 'http_rate_limit', + timestamp: T + 500, + correlationConfidence: 'window', + }, + ]; + const log = makeFakeLog([...ownerAEvents, ...ownerBEvents]); + const app = await createDirectRouteApp({ guardRejectionLog: log }); + const thread = await threadStore.create('owner-A', 'tenant-test'); + const { invocationId, callbackToken } = await registry.create('owner-A', 'codex', thread.id); + + const response = await app.inject({ + method: 'GET', + url: `/api/callbacks/guard-rejections?ledgerId=mcp/hold-ball-rate-limit&sinceMs=${T - 1000}&untilMs=${T + 5000}`, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + }); + + assert.equal(response.statusCode, 200); + const body = response.json(); + assert.equal(body.events.length, 2, 'owner-A sees exactly 2 events'); + assert.ok( + body.events.every((e) => e.ownerUserId === 'owner-A'), + 'no cross-tenant leakage', + ); + }); +}); + +// --------------------------------------------------------------------------- +// P2-3: stats SCARD error → { available: false } (direct route) +// --------------------------------------------------------------------------- + +describe('P2-3: stats SCARD error → available: false', async () => { + await setup(); + + it('GET returns stats.available=false when SCARD throws', async () => { + const events = [ + { + eventId: 'e1', + ledgerId: 'mcp/hold-ball-rate-limit', + ownerUserId: 'user-stats', + threadId: 't1', + catId: 'c1', + guardId: 'hold_ball_rate_limit', + kind: 'http_rate_limit', + timestamp: T, + correlationConfidence: 'window', + }, + ]; + const log = makeFakeLog(events); + const failingLedgerStats = { + async anomalyReferenceCount() { + throw new Error('READONLY: Redis failover'); + }, + }; + const app = await createDirectRouteApp({ guardRejectionLog: log, ledgerStats: failingLedgerStats }); + const thread = await threadStore.create('user-stats', 'stats-test'); + const { invocationId, callbackToken } = await registry.create('user-stats', 'codex', thread.id); + + const response = await app.inject({ + method: 'GET', + url: `/api/callbacks/guard-rejections?ledgerId=mcp/hold-ball-rate-limit&sinceMs=${T - 1000}&untilMs=${T + 5000}`, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + }); + + assert.equal(response.statusCode, 200, 'events still returned — partial success'); + const body = response.json(); + assert.equal(body.events.length, 1); + assert.equal(body.stats.available, false, 'stats degraded to available: false'); + assert.equal(body.stats.reason, 'scard_error'); + }); +}); + +// --------------------------------------------------------------------------- +// P2-4③: >cap pagination via real GuardRejectionEventLog + LIMIT-aware Redis +// sol R4: must instantiate real EventLog with a Redis fake that supports +// ZRANGEBYSCORE LIMIT, insert 10,001 events, and observe HARD_QUERY_CAP +// truncation through the actual paging code path. +// --------------------------------------------------------------------------- + +/** + * Minimal Redis fake supporting ZRANGEBYSCORE with LIMIT (the paging + * primitive GuardRejectionEventLog.fetchWindow uses). Members stored + * sorted by score for correct range + offset semantics. + */ +function createZsetRedis() { + const members = []; + return { + async zadd(_key, score, member) { + members.push({ score: Number(score), member }); + return 1; + }, + async zrangebyscore(_key, min, max, ...args) { + let offset = 0; + let count = members.length; + for (let i = 0; i < args.length; i++) { + if (String(args[i]).toUpperCase() === 'LIMIT') { + offset = Number(args[i + 1]); + count = Number(args[i + 2]); + break; + } + } + // Must be sorted by score for correct LIMIT behavior + const sorted = [...members].sort((a, b) => a.score - b.score); + return sorted + .filter((m) => m.score >= Number(min) && m.score <= Number(max)) + .slice(offset, offset + count) + .map((m) => m.member); + }, + async zremrangebyscore() { + return 0; + }, + }; +} + +describe('P2-4③: >HARD_QUERY_CAP via real EventLog + LIMIT-aware Redis', async () => { + await setup(); + + it('real EventLog returns truncated=true when 10,001 matching events exceed cap', async () => { + const { GuardRejectionEventLog } = await import('../../dist/infrastructure/harness-eval/GuardRejectionEventLog.js'); + const redis = createZsetRedis(); + const realLog = new GuardRejectionEventLog(redis); + + // Seed 10,001 events — exceeds HARD_QUERY_CAP (10,000) + for (let i = 0; i < 10_001; i++) { + await realLog.append({ + eventId: `cap-evt-${i}`, + ledgerId: 'mcp/hold-ball-rate-limit', + ownerUserId: 'user-cap', + threadId: 't1', + catId: 'c1', + guardId: 'hold_ball_rate_limit', + kind: 'http_rate_limit', + timestamp: T + i * 100, + correlationConfidence: 'window', + invocationId: 'inv-1', + sourceTool: 'hold_ball', + normalizedReason: 'rate_limited', + layer: 'api-route', + currentCount: 5, + maxAllowed: 5, + windowMs: 3600000, + }); + } + + // Query through the real EventLog's fetchWindow paging path + const { events, truncated } = await realLog.queryWindowStrictComplete({ + since: T, + until: T + 2_000_000, + ownerUserId: 'user-cap', + }); + + assert.equal(truncated, true, 'HARD_QUERY_CAP reached → truncated=true'); + assert.equal(events.length, 10_000, 'exactly cap events returned'); + + // Verify this surfaces through the GET route + const app = await createDirectRouteApp({ guardRejectionLog: realLog }); + const thread = await threadStore.create('user-cap', 'cap-route-test'); + const { invocationId, callbackToken } = await registry.create('user-cap', 'codex', thread.id); + + const response = await app.inject({ + method: 'GET', + url: `/api/callbacks/guard-rejections?ledgerId=mcp/hold-ball-rate-limit&sinceMs=${T}&untilMs=${T + 2_000_000}`, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + }); + + assert.equal(response.statusCode, 200); + const body = response.json(); + assert.equal(body.truncated, true, 'truncated flag reaches the HTTP caller'); + assert.equal(body.events.length, 10_000, 'route-level cap enforcement'); + }); +}); + +// --------------------------------------------------------------------------- +// P2-5: skip kind ingest guard + ledgerId trust boundary +// Architecture contract: route_decision_skip is emitted SERVER-SIDE only +// (route-serial.ts:3107,3326 via guardRejectionLog.append). The MCP POST +// schema rejects it. Full emit-path coverage lives in route-serial +// integration tests (route-serial-routing-guard-remedial.test.js). +// --------------------------------------------------------------------------- + +describe('P2-5: route_decision_skip kind guard', async () => { + await setup(); + + it('MCP POST rejects route_decision_skip kind (Zod enum guard)', async () => { + const log = makeFakeLog(); + const app = await createApp(log); + const thread = await threadStore.create('user-no-mcp-skip', 'no-mcp-skip'); + const { invocationId, callbackToken } = await registry.create('user-no-mcp-skip', 'codex', thread.id); + + const response = await app.inject({ + method: 'POST', + url: '/api/callbacks/guard-rejections', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { + kind: 'route_decision_skip', + guardId: 'a2a_route_decision_skip', + sourceTool: 'route_callback', + normalizedReason: 'dedup_active', + }, + }); + + assert.equal(response.statusCode, 400, 'skip kind is server-side only — rejected on POST'); + assert.ok(response.json().issues[0].includes('kind'), 'error surfaces the field name'); + assert.equal(log._appended.length, 0, 'nothing appended'); + }); + + it('MCP POST accepts valid MCP kinds (positive counterexample)', async () => { + const log = makeFakeLog(); + const app = await createApp(log); + const thread = await threadStore.create('user-valid-kind', 'valid-kind'); + const { invocationId, callbackToken } = await registry.create('user-valid-kind', 'codex', thread.id); + + const response = await app.inject({ + method: 'POST', + url: '/api/callbacks/guard-rejections', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { + kind: 'http_policy_reject', + guardId: 'cross_post_routing_credentials', + sourceTool: 'cross_post_message', + normalizedReason: 'missing_credentials', + }, + }); + + assert.equal(response.statusCode, 202, 'valid MCP kind accepted'); + assert.equal(log._appended.length, 1, 'event appended'); + }); +}); + +// --------------------------------------------------------------------------- +// P2-1 (sol R4): GET rejects unregistered ledgerId at API boundary +// --------------------------------------------------------------------------- + +describe('P2-1: GET rejects unregistered ledgerId', async () => { + await setup(); + + it('returns 400 for spoofed ledgerId', async () => { + const log = makeFakeLog(); + const app = await createDirectRouteApp({ guardRejectionLog: log }); + const thread = await threadStore.create('user-spoof', 'spoof-test'); + const { invocationId, callbackToken } = await registry.create('user-spoof', 'codex', thread.id); + + const response = await app.inject({ + method: 'GET', + url: `/api/callbacks/guard-rejections?ledgerId=evil/fake-pot&sinceMs=${T}&untilMs=${T + 5000}`, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + }); + + assert.equal(response.statusCode, 400, 'unregistered ledgerId → 400'); + assert.ok(response.json().error.includes('unregistered'), 'error message names the issue'); + assert.ok(Array.isArray(response.json().registered), 'response includes valid options'); + }); + + it('returns 400 for prototype-like ledgerId', async () => { + const log = makeFakeLog(); + const app = await createDirectRouteApp({ guardRejectionLog: log }); + const thread = await threadStore.create('user-proto', 'proto-test'); + const { invocationId, callbackToken } = await registry.create('user-proto', 'codex', thread.id); + + const response = await app.inject({ + method: 'GET', + url: `/api/callbacks/guard-rejections?ledgerId=toString&sinceMs=${T}&untilMs=${T + 5000}`, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + }); + + assert.equal(response.statusCode, 400, 'prototype key → 400'); + }); + + it('returns 200 for registered ledgerId', async () => { + const log = makeFakeLog(); + const app = await createDirectRouteApp({ guardRejectionLog: log }); + const thread = await threadStore.create('user-valid-ledger', 'valid-test'); + const { invocationId, callbackToken } = await registry.create('user-valid-ledger', 'codex', thread.id); + + const response = await app.inject({ + method: 'GET', + url: `/api/callbacks/guard-rejections?ledgerId=mcp/hold-ball-rate-limit&sinceMs=${T}&untilMs=${T + 5000}`, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + }); + + assert.equal(response.statusCode, 200, 'registered ledgerId → 200'); + }); +}); diff --git a/packages/api/test/harness-eval/guard-rejection-r5-route-skip.test.js b/packages/api/test/harness-eval/guard-rejection-r5-route-skip.test.js new file mode 100644 index 0000000000..c7118637df --- /dev/null +++ b/packages/api/test/harness-eval/guard-rejection-r5-route-skip.test.js @@ -0,0 +1,231 @@ +/** + * F257 V2 R5 P2-2 — routeSerial route_decision_skip event emission. + * + * When hasQueuedOrActiveAgentForCat returns true for a mentioned cat, + * routeSerial skips the cat and emits a `route_decision_skip` event + * via guardRejectionLog. This test drives the REAL routeSerial with + * a controlled hasQueuedOrActiveAgentForCat to verify: + * + * 1. Skip case: target NOT invoked, exactly 1 skip event with correct fields + * 2. No-skip counterexample: target invoked, zero skip events + * + * [opus/claude-opus-4-6🐾] + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { catRegistry } from '@cat-cafe/shared'; + +// --------------------------------------------------------------------------- +// Concurrency guard — catRegistry is global; serialise mutating tests +// --------------------------------------------------------------------------- + +let catRegistryLock = Promise.resolve(); + +function withCatRegistryLock(fn) { + const previous = catRegistryLock; + let release; + catRegistryLock = new Promise((resolve) => { + release = resolve; + }); + return previous.then(() => fn().finally(release)); +} + +// --------------------------------------------------------------------------- +// Helpers (minimal subset of route-serial-routing-guard-remedial rig) +// --------------------------------------------------------------------------- + +function createSequenceService(catId, texts, { needsGuard = true } = {}) { + const calls = []; + return { + calls, + needsServerRoutingGuard: () => needsGuard, + async *invoke(prompt) { + calls.push(prompt); + const turn = texts[Math.min(calls.length - 1, texts.length - 1)] ?? ''; + yield { + type: 'system_info', + catId, + content: JSON.stringify({ type: 'invocation_created', invocationId: `${catId}-inv-${calls.length}` }), + timestamp: Date.now(), + }; + const events = Array.isArray(turn) ? turn : [{ type: 'text', content: turn }]; + for (const event of events) { + yield { catId, timestamp: Date.now(), ...event }; + } + yield { type: 'done', catId, timestamp: Date.now() }; + }, + }; +} + +function createMockGuardRejectionLog() { + const events = []; + return { + events, + append: async (event) => { + events.push(event); + return event; + }, + }; +} + +function createMockDeps(services, appendedMessages, { guardRejectionLog } = {}) { + let counter = 0; + const deps = { + services, + invocationDeps: { + registry: { + create: () => ({ invocationId: `outer-inv-${++counter}`, callbackToken: `tok-${counter}` }), + verify: async () => ({ ok: false, reason: 'unknown_invocation' }), + }, + sessionManager: { + getOrCreate: async () => ({}), + get: async () => null, + resolveWorkingDirectory: () => '/tmp/test', + }, + threadStore: null, + apiUrl: 'http://127.0.0.1:3004', + }, + messageStore: { + append: async (msg) => { + const stored = { + id: `msg-${++counter}`, + userId: msg.userId ?? '', + catId: msg.catId ?? null, + content: msg.content ?? '', + mentions: msg.mentions ?? [], + timestamp: msg.timestamp ?? 0, + source: msg.source, + origin: msg.origin, + mentionsUser: msg.mentionsUser, + toolEvents: msg.toolEvents, + extra: msg.extra, + }; + appendedMessages.push(stored); + return stored; + }, + getById: () => null, + getRecent: () => [], + getMentionsFor: () => [], + getBefore: () => [], + getByThread: () => [], + getByThreadAfter: () => [], + getByThreadBefore: () => [], + augmentStreamMetadata: async () => true, + }, + draftStore: { + upsert: () => {}, + touch: () => {}, + delete: () => Promise.resolve(), + deleteByThread: () => {}, + getByThread: () => [], + }, + socketManager: { + broadcastToRoom() {}, + }, + }; + if (guardRejectionLog) { + deps.guardRejectionLog = guardRejectionLog; + } + return deps; +} + +async function loadRealRoster() { + const { loadCatConfig, toAllCatConfigs } = await import('../../dist/config/cat-config-loader.js'); + const runtimeConfigs = toAllCatConfigs(loadCatConfig()); + catRegistry.reset(); + for (const [id, config] of Object.entries(runtimeConfigs)) { + catRegistry.register(id, config); + } +} + +async function runRoute(codexService, threadId, { extraServices = {}, routeOptions = {}, guardRejectionLog } = {}) { + return withCatRegistryLock(async () => { + const original = catRegistry.getAllConfigs(); + await loadRealRoster(); + const appended = []; + try { + const { routeSerial } = await import('../../dist/domains/cats/services/agents/routing/route-serial.js'); + const deps = createMockDeps({ codex: codexService, ...extraServices }, appended, { guardRejectionLog }); + const yielded = []; + for await (const msg of routeSerial(deps, ['codex'], 'skip test', 'user1', threadId, { + thinkingMode: 'play', + ...routeOptions, + })) { + yielded.push(msg); + } + return { appended, yielded, codexCalls: codexService.calls }; + } finally { + catRegistry.reset(); + for (const [id, config] of Object.entries(original)) { + catRegistry.register(id, config); + } + } + }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('F257 V2 P2-2: route_decision_skip event emission', () => { + test('skip: target NOT invoked, exactly 1 skip event with correct fields', async () => { + const codexService = createSequenceService('codex', ['I will keep going from here.', '@opus']); + const opusService = createSequenceService('opus', ['ack from opus'], { needsGuard: false }); + const log = createMockGuardRejectionLog(); + + const { codexCalls } = await runRoute(codexService, 'thread-f257-skip-emit', { + extraServices: { opus: opusService }, + guardRejectionLog: log, + routeOptions: { + hasQueuedOrActiveAgentForCat: (_threadId, catId) => catId === 'opus', + }, + }); + + // codex runs initial + remedial (routing guard: needsGuard=true) + assert.equal(codexCalls.length, 2, 'codex initial + remedial'); + // opus should NOT be invoked (skipped due to active agent) + assert.equal(opusService.calls.length, 0, 'opus must not be invoked when hasActiveAgent=true'); + // exactly 1 skip event + const skipEvents = log.events.filter((e) => e.kind === 'route_decision_skip'); + assert.equal(skipEvents.length, 1, 'exactly one route_decision_skip event'); + + // Full octet + dual-coordinate contract assertion (sol R6 P3-1) + const evt = skipEvents[0]; + assert.ok(evt.eventId, 'eventId must be present'); + assert.ok(evt.ledgerId, 'ledgerId must be present'); + assert.equal(evt.kind, 'route_decision_skip'); + assert.equal(evt.guardId, 'a2a_route_decision_skip'); + assert.equal(evt.threadId, 'thread-f257-skip-emit', 'threadId must match route threadId'); + assert.equal(evt.catId, 'codex', 'catId must be the CALLER cat (codex), not the target'); + assert.equal(evt.invocationId, 'unknown', 'invocationId is unknown for skip path'); + assert.equal(evt.sourceTool, 'a2a_mention'); + assert.equal(evt.normalizedReason, 'dedup_active'); + assert.equal(evt.layer, 'generator'); + assert.equal(evt.correlationConfidence, 'window'); + assert.ok(evt.timestamp > 0, 'timestamp must be positive'); + assert.equal(evt.ownerUserId, 'user1', 'ownerUserId must match the caller'); + assert.equal(evt.targetCatId, 'opus', 'targetCatId must be the skipped cat'); + assert.equal(evt.skipReason, 'dedup_active', 'skipReason must match decision reason'); + }); + + test('no-skip counterexample: target invoked, zero skip events', async () => { + const codexService = createSequenceService('codex', ['I will keep going from here.', '@opus']); + const opusService = createSequenceService('opus', ['ack from opus'], { needsGuard: false }); + const log = createMockGuardRejectionLog(); + + await runRoute(codexService, 'thread-f257-no-skip', { + extraServices: { opus: opusService }, + guardRejectionLog: log, + routeOptions: { + hasQueuedOrActiveAgentForCat: () => false, + }, + }); + + // opus should be invoked normally + assert.equal(opusService.calls.length, 1, 'opus must be invoked when hasActiveAgent=false'); + // zero skip events + const skipEvents = log.events.filter((e) => e.kind === 'route_decision_skip'); + assert.equal(skipEvents.length, 0, 'no skip events when routing proceeds normally'); + }); +}); diff --git a/packages/api/test/harness-eval/guard-threshold-escalation.test.js b/packages/api/test/harness-eval/guard-threshold-escalation.test.js new file mode 100644 index 0000000000..a96e51f9b6 --- /dev/null +++ b/packages/api/test/harness-eval/guard-threshold-escalation.test.js @@ -0,0 +1,654 @@ +import assert from 'node:assert/strict'; +import { describe, it, mock } from 'node:test'; +import { + checkGuardThreshold, + createThresholdEscalationHook, + ESCALATION_THRESHOLD, + ESCALATION_WINDOW_DAYS, +} from '../../dist/infrastructure/harness-eval/guard-threshold-escalation.js'; +import { createFakeEventSource, createFakeRedis, T, triggerSuccess } from './_guard-test-helpers.js'; + +// --------------------------------------------------------------------------- +// Helpers (test-specific — canonical fake Redis is in _guard-test-helpers.js) +// --------------------------------------------------------------------------- + +/** + * Create N SEPARATED events (10 min apart — far beyond EPISODE_GAP_MS 60s, + * so each event forms its own episode). All share the same guardId. + */ +function createEvents(count, guardId = 'hold_ball_rate_limit', ownerUserId = 'user_1') { + return Array.from({ length: count }, (_, i) => ({ + eventId: `evt-${guardId}-${i}`, + kind: 'http_rate_limit', + threadId: 'thread_1', + catId: 'cat_1', + guardId, + ownerUserId, + timestamp: T + i * 600_000, + correlationConfidence: 'window', + currentCount: 5, + maxAllowed: 5, + windowMs: 3600000, + })); +} + +function makeEvent(guardId = 'hold_ball_rate_limit', timestamp = T + 5_000_000, ownerUserId = 'user_1') { + return { + eventId: `evt-${timestamp}`, + kind: 'http_rate_limit', + threadId: 'thread_1', + catId: 'cat_1', + guardId, + ownerUserId, + timestamp, + correlationConfidence: 'window', + currentCount: 5, + maxAllowed: 5, + windowMs: 3600000, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('F257 sub-item 2: guard threshold escalation', () => { + it('exports correct threshold constants', () => { + assert.equal(ESCALATION_THRESHOLD, 3, 'threshold should be 3 events'); + assert.equal(ESCALATION_WINDOW_DAYS, 7, 'window should be 7 days'); + }); + + it('does NOT escalate when count < threshold', async () => { + const events = createEvents(2); + const { redis, guardRejectionLog } = await createFakeEventSource(events); + const triggerEval = mock.fn(async () => ({ ok: true })); + + const result = await checkGuardThreshold(makeEvent(), { redis, guardRejectionLog, triggerEval }); + + assert.equal(result.checked, true); + assert.equal(result.thresholdMet, false); + assert.equal(result.escalated, false); + assert.equal(triggerEval.mock.callCount(), 0, 'should NOT trigger eval'); + }); + + it('escalates when count >= threshold (first time)', async () => { + const guardId = 'guard-x'; + const events = createEvents(3, guardId); + const { redis, guardRejectionLog } = await createFakeEventSource(events); + const triggerEval = mock.fn(async () => triggerSuccess()); + + const result = await checkGuardThreshold(makeEvent(guardId), { redis, guardRejectionLog, triggerEval }); + + assert.equal(result.checked, true); + assert.equal(result.thresholdMet, true); + assert.equal(result.alreadyEscalated, false); + assert.equal(result.escalated, true); + assert.equal(result.episodeCount, 3); + + // triggerEval called with eval:harness-ledger and real ownerUserId (sol R9 P1-1) + assert.equal(triggerEval.mock.callCount(), 1); + const triggerInput = triggerEval.mock.calls[0].arguments[0]; + assert.equal(triggerInput.domainId, 'eval:harness-ledger'); + assert.equal(triggerInput.userId, 'user_1', 'userId must be real ownerUserId, not synthetic'); + }); + + it('does NOT re-escalate same guard (dedup key exists)', async () => { + const guardId = 'guard-y'; + const events = createEvents(5, guardId); + const { redis, guardRejectionLog } = await createFakeEventSource(events); + const triggerEval = mock.fn(async () => triggerSuccess()); + + // First call: escalates + const event = makeEvent(guardId); + const first = await checkGuardThreshold(event, { redis, guardRejectionLog, triggerEval }); + assert.equal(first.escalated, true); + + // Second call: dedup key exists → should NOT re-escalate + const second = await checkGuardThreshold(event, { redis, guardRejectionLog, triggerEval }); + assert.equal(second.thresholdMet, true); + assert.equal(second.alreadyEscalated, true); + assert.equal(second.escalated, false); + + // triggerEval called only ONCE (first time) + assert.equal(triggerEval.mock.callCount(), 1, 'should only trigger once per dedup window'); + }); + + it('dedup key is set in Redis with correct prefix', async () => { + const guardId = 'guard-z'; + const events = createEvents(3, guardId); + const { redis, guardRejectionLog } = await createFakeEventSource(events); + const triggerEval = mock.fn(async () => triggerSuccess()); + + await checkGuardThreshold(makeEvent(guardId), { redis, guardRejectionLog, triggerEval }); + + // Check Redis store for dedup key (includes ownerUserId — sol R9 P1-1) + const dedupKey = 'guard-rejection:escalated:user_1:guard-z'; + const stored = redis._store.get(dedupKey); + assert.ok(stored, 'dedup key should exist in Redis'); + const parsed = JSON.parse(stored); + assert.ok(parsed.count >= 0, 'count must be present'); + assert.ok(parsed.escalatedAt, 'should record escalation timestamp'); + assert.ok(parsed.triggeredBy, 'should record triggering event ID'); + }); + + it('different guards escalate independently', async () => { + // Seed events for BOTH guards into the same Redis + const eventsA = createEvents(4, 'guard-a'); + const eventsB = createEvents(4, 'guard-b'); + const { redis, guardRejectionLog } = await createFakeEventSource([...eventsA, ...eventsB]); + const triggerEval = mock.fn(async () => triggerSuccess()); + + const r1 = await checkGuardThreshold(makeEvent('guard-a'), { redis, guardRejectionLog, triggerEval }); + const r2 = await checkGuardThreshold(makeEvent('guard-b'), { redis, guardRejectionLog, triggerEval }); + + assert.equal(r1.escalated, true, 'guard-a should escalate'); + assert.equal(r2.escalated, true, 'guard-b should escalate independently'); + assert.equal(triggerEval.mock.callCount(), 2, 'both guards should trigger eval'); + }); + + it('pagewise counter queries correct window and filters by guardId', async () => { + const guardId = 'guard-q'; + const events = createEvents(1, guardId); + const { redis, guardRejectionLog } = await createFakeEventSource(events); + const triggerEval = mock.fn(async () => ({})); + const now = T + 5_000_000; + + // Spy on zrangebyscore to verify query parameters + const zrangebyscoreCalls = []; + const originalZrange = redis.zrangebyscore.bind(redis); + redis.zrangebyscore = async (...args) => { + zrangebyscoreCalls.push(args); + return originalZrange(...args); + }; + + await checkGuardThreshold(makeEvent(guardId, now), { redis, guardRejectionLog, triggerEval }); + + assert.ok(zrangebyscoreCalls.length >= 1, 'should call zrangebyscore'); + const [, min, max] = zrangebyscoreCalls[0]; + const expectedWindowMs = ESCALATION_WINDOW_DAYS * 24 * 3600 * 1000; + assert.equal(min, now - expectedWindowMs, 'min should be event.timestamp - 7 days'); + assert.equal(max, now, 'max should be event.timestamp (half-open via until-1)'); + }); + + it('concurrent threshold checks only trigger once (atomic SET NX)', async () => { + const guardId = 'guard-race'; + const events = createEvents(4, guardId); + const { redis, guardRejectionLog } = await createFakeEventSource(events); + const triggerEval = mock.fn(async () => triggerSuccess()); + + const event = makeEvent(guardId); + // Simulate two concurrent checks — both see threshold met, + // but only one wins the atomic SET NX claim. + const [r1, r2] = await Promise.all([ + checkGuardThreshold(event, { redis, guardRejectionLog, triggerEval }), + checkGuardThreshold(event, { redis, guardRejectionLog, triggerEval }), + ]); + + const escalated = [r1, r2].filter((r) => r.escalated); + const deduped = [r1, r2].filter((r) => r.alreadyEscalated); + assert.equal(escalated.length, 1, 'exactly one should win the claim'); + assert.equal(deduped.length, 1, 'exactly one should be deduped'); + assert.equal(triggerEval.mock.callCount(), 1, 'triggerEval called exactly once'); + }); + + it('atomic claim sets TTL via SET EX (no separate expire call)', async () => { + const guardId = 'guard-ttl'; + const events = createEvents(3, guardId); + const { redis, guardRejectionLog } = await createFakeEventSource(events); + // Track the set call args to verify EX and NX are passed + const setCalls = []; + const originalSet = redis.set.bind(redis); + redis.set = async (key, value, ...args) => { + setCalls.push({ key, args }); + return originalSet(key, value, ...args); + }; + const triggerEval = mock.fn(async () => triggerSuccess()); + + await checkGuardThreshold(makeEvent(guardId), { redis, guardRejectionLog, triggerEval }); + + const dedupSet = setCalls.find((c) => c.key.startsWith('guard-rejection:escalated:')); + assert.ok(dedupSet, 'should SET dedup key'); + assert.ok(dedupSet.args.includes('EX'), 'should include EX for TTL'); + assert.ok(dedupSet.args.includes('NX'), 'should include NX for atomic claim'); + assert.ok(dedupSet.args.includes(604800), 'TTL should be 7 days in seconds'); + }); + + it('releases claim when triggerEval returns 503 invokeTrigger not ready', async () => { + const guardId = 'guard-503'; + const events = createEvents(3, guardId); + const { redis, guardRejectionLog } = await createFakeEventSource(events); + const callCount = { n: 0 }; + const triggerEval = mock.fn(async () => { + callCount.n++; + if (callCount.n === 1) { + return { status: 503, error: 'invokeTrigger not ready' }; + } + return triggerSuccess(); + }); + + // First threshold check: claim + trigger 503 → claim released + const first = await checkGuardThreshold(makeEvent(guardId), { redis, guardRejectionLog, triggerEval }); + assert.equal(first.thresholdMet, true); + assert.equal(first.escalated, false, 'should NOT report escalated on 503'); + assert.equal(first.claimReleased, true, 'claim should be released'); + assert.equal(redis._store.has('guard-rejection:escalated:user_1:guard-503'), false, 'dedup key should be deleted'); + + // Second threshold check: claim succeeds (key was released) → trigger dispatched + const second = await checkGuardThreshold(makeEvent(guardId), { redis, guardRejectionLog, triggerEval }); + assert.equal(second.escalated, true, 'should escalate on retry'); + assert.equal(second.claimReleased, undefined, 'no claim release on success'); + assert.equal(triggerEval.mock.callCount(), 2, 'triggerEval called twice (503 + success)'); + }); + + it('releases claim when triggerEval returns queue full', async () => { + const guardId = 'guard-full'; + const events = createEvents(5, guardId); + const { redis, guardRejectionLog } = await createFakeEventSource(events); + const triggerEval = mock.fn(async () => ({ + status: 503, + error: 'invocation_queue_full', + detail: 'queue at capacity', + })); + + const result = await checkGuardThreshold(makeEvent(guardId), { redis, guardRejectionLog, triggerEval }); + assert.equal(result.thresholdMet, true); + assert.equal(result.escalated, false, 'should NOT report escalated on queue full'); + assert.equal(result.claimReleased, true); + assert.equal(redis._store.has('guard-rejection:escalated:user_1:guard-full'), false, 'claim released'); + }); + + it('releases claim when triggerEval returns TriggerNowSkipped (zero events)', async () => { + const guardId = 'guard-skip'; + const events = createEvents(3, guardId); + const { redis, guardRejectionLog } = await createFakeEventSource(events); + const triggerEval = mock.fn(async () => ({ + ok: true, + domainId: 'eval:harness-ledger', + skipped: true, + reason: 'zero_events_in_window', + evalRunId: 'hlr-123-abcd1234', + windowSummary: '168h window, 0 events', + })); + + const result = await checkGuardThreshold(makeEvent(guardId), { redis, guardRejectionLog, triggerEval }); + assert.equal(result.escalated, false, 'skipped is not escalated'); + assert.equal(result.claimReleased, true, 'claim released on skip'); + }); + + it('keeps claim when triggerEval returns dispatched success', async () => { + const guardId = 'guard-ok'; + const events = createEvents(3, guardId); + const { redis, guardRejectionLog } = await createFakeEventSource(events); + const triggerEval = mock.fn(async () => triggerSuccess()); + + const result = await checkGuardThreshold(makeEvent(guardId), { redis, guardRejectionLog, triggerEval }); + assert.equal(result.escalated, true); + assert.equal(result.claimReleased, undefined, 'claim should NOT be released on success'); + assert.ok(redis._store.has('guard-rejection:escalated:user_1:guard-ok'), 'dedup key retained'); + }); + + // ---- Round 4 regression: triggerEval reject + DEL reject paths ---- + + it('releases claim when triggerEval rejects (throw) → next event retries successfully', async () => { + const guardId = 'guard-throw'; + const events = createEvents(3, guardId); + const { redis, guardRejectionLog } = await createFakeEventSource(events); + const callCount = { n: 0 }; + const triggerEval = mock.fn(async () => { + callCount.n++; + if (callCount.n === 1) { + throw new Error('messageStore.append ECONNRESET'); + } + return triggerSuccess(); + }); + + // First call: triggerEval throws → catch releases claim via DEL + const first = await checkGuardThreshold(makeEvent(guardId), { redis, guardRejectionLog, triggerEval }); + assert.equal(first.thresholdMet, true); + assert.equal(first.escalated, false, 'reject path must NOT report escalated'); + assert.equal(first.claimReleased, true, 'claim released after triggerEval reject'); + assert.equal(first.triggerResult, undefined, 'no triggerResult on reject path'); + assert.equal( + redis._store.has('guard-rejection:escalated:guard-throw'), + false, + 'dedup key deleted — next event can retry', + ); + + // Second call: fresh claim succeeds → eval cat invoked + const second = await checkGuardThreshold(makeEvent(guardId), { redis, guardRejectionLog, triggerEval }); + assert.equal(second.escalated, true, 'retry succeeds after claim release'); + assert.equal(triggerEval.mock.callCount(), 2, 'triggerEval called twice (reject + success)'); + }); + + it('reports claimReleased=false when redis.del rejects (7d TTL backstop)', async () => { + const guardId = 'guard-del-fail'; + const events = createEvents(3, guardId); + const { redis, guardRejectionLog } = await createFakeEventSource(events); + // triggerEval returns 503 (resolved, not throw) to enter non-dispatch path + const triggerEval = mock.fn(async () => ({ + status: 503, + error: 'invokeTrigger not ready', + })); + + // Sabotage redis.del to reject + redis.del = async () => { + throw new Error("READONLY You can't write against a read only replica"); + }; + + // Capture console.warn + const warnings = []; + const originalWarn = console.warn; + console.warn = (...args) => warnings.push(args); + + try { + const result = await checkGuardThreshold(makeEvent(guardId), { redis, guardRejectionLog, triggerEval }); + + assert.equal(result.thresholdMet, true); + assert.equal(result.escalated, false); + assert.equal(result.claimReleased, false, 'must NOT report true when DEL failed'); + + // Key survives — 7d TTL backstop is active + assert.ok( + redis._store.has('guard-rejection:escalated:user_1:guard-del-fail'), + 'dedup key still exists (TTL backstop)', + ); + + // console.warn was called with F257 prefix + assert.ok(warnings.length >= 1, 'console.warn should fire on DEL failure'); + assert.ok(warnings[0][0].includes('[F257]'), 'warning should include [F257] prefix'); + } finally { + console.warn = originalWarn; + } + }); +}); + +describe('createThresholdEscalationHook', () => { + it('returns a synchronous function (fire-and-forget pattern)', async () => { + const { redis, guardRejectionLog } = await createFakeEventSource(); + const hook = createThresholdEscalationHook({ + redis, + guardRejectionLog, + triggerEval: async () => ({ status: 503, error: 'test' }), + }); + + assert.equal(typeof hook, 'function'); + // Calling it should not throw (fire-and-forget) + assert.doesNotThrow(() => hook(makeEvent())); + }); +}); + +// --------------------------------------------------------------------------- +// Bootstrap integration test: real GuardRejectionEventLog + hook wiring +// --------------------------------------------------------------------------- + +describe('F257 bootstrap integration: append → threshold escalation', async () => { + const { GuardRejectionEventLog } = await import('../../dist/infrastructure/harness-eval/GuardRejectionEventLog.js'); + + /** + * Combined FakeRedis that supports both ZSET ops (for GuardRejectionEventLog) + * and key-value ops with SET NX EX (for threshold escalation dedup). + */ + function createFullFakeRedis() { + const store = new Map(); + const sorted = new Map(); + return { + // Key-value (dedup) + get: async (key) => store.get(key) ?? null, + set: async (key, value, ...args) => { + const hasNX = args.includes('NX'); + if (hasNX && store.has(key)) return null; + store.set(key, value); + return 'OK'; + }, + del: async (key) => { + const existed = store.has(key); + store.delete(key); + return existed ? 1 : 0; + }, + expire: async () => 1, + // Sorted set (event log) + zadd: async (key, score, member) => { + const s = sorted.get(key) ?? new Map(); + s.set(member, score); + sorted.set(key, s); + return 1; + }, + zrangebyscore: async (key, min, max, ...args) => { + const s = sorted.get(key); + if (!s) return []; + let offset = 0; + let count = s.size; + for (let i = 0; i < args.length; i++) { + if (String(args[i]).toUpperCase() === 'LIMIT') { + offset = Number(args[i + 1]); + count = Number(args[i + 2]); + break; + } + } + return [...s.entries()] + .filter(([, sc]) => sc >= min && sc <= max) + .sort((a, b) => a[1] - b[1]) + .slice(offset, offset + count) + .map(([m]) => m); + }, + zremrangebyscore: async (key, min, max) => { + const s = sorted.get(key); + if (!s) return 0; + let removed = 0; + for (const [member, score] of s) { + if (score >= min && score <= max) { + s.delete(member); + removed++; + } + } + return removed; + }, + _store: store, + }; + } + + it('real append fires hook → triggerEval called at threshold', async () => { + const redis = createFullFakeRedis(); + const log = new GuardRejectionEventLog(redis); + const triggerEval = mock.fn(async () => triggerSuccess()); + + // Wire hook — mirrors index.ts bootstrap pattern + const hook = createThresholdEscalationHook({ redis, guardRejectionLog: log, triggerEval }); + log.setPostAppendHook(hook); + + const now = T; + + // PR #41 episode accounting: appends are separated by >60s gaps so each + // forms a distinct episode (a 1ms-apart burst would coalesce into ONE + // episode and correctly NOT trigger — covered in the coalescing suite). + // Append 2 separated events (below threshold) — no trigger + await log.append(makeEvent('guard-boot', now)); + await log.append(makeEvent('guard-boot', now + 100_000)); + // Give fire-and-forget hooks time to settle + await new Promise((r) => setTimeout(r, 50)); + assert.equal(triggerEval.mock.callCount(), 0, 'below threshold: no trigger'); + + // Append 3rd separated event (reaches 3 episodes) — triggers + await log.append(makeEvent('guard-boot', now + 200_000)); + await new Promise((r) => setTimeout(r, 50)); + assert.equal(triggerEval.mock.callCount(), 1, 'at threshold: trigger fires'); + + // Verify trigger input — userId is real ownerUserId (sol R9 P1-1) + const input = triggerEval.mock.calls[0].arguments[0]; + assert.equal(input.domainId, 'eval:harness-ledger'); + assert.equal(input.userId, 'user_1', 'trigger userId must be real ownerUserId'); + }); + + it('real append: trigger receives real ownerUserId, not synthetic', async () => { + const redis = createFullFakeRedis(); + const log = new GuardRejectionEventLog(redis); + const triggerEval = mock.fn(async () => triggerSuccess()); + + const hook = createThresholdEscalationHook({ redis, guardRejectionLog: log, triggerEval }); + log.setPostAppendHook(hook); + + const now = T; + // 3 separated events from owner-real-owner + for (let i = 0; i < 3; i++) { + await log.append(makeEvent('guard-owner-test', now + i * 100_000, 'real-owner-id')); + } + await new Promise((r) => setTimeout(r, 50)); + assert.equal(triggerEval.mock.callCount(), 1); + const input = triggerEval.mock.calls[0].arguments[0]; + assert.equal(input.userId, 'real-owner-id', 'must receive real ownerUserId'); + }); + + it('real append: 4th event does NOT re-trigger (dedup)', async () => { + const redis = createFullFakeRedis(); + const log = new GuardRejectionEventLog(redis); + const triggerEval = mock.fn(async () => triggerSuccess()); + + const hook = createThresholdEscalationHook({ redis, guardRejectionLog: log, triggerEval }); + log.setPostAppendHook(hook); + + const now = T; + // Append 4 SEPARATED events (>60s gaps → 4 distinct episodes) — + // 3rd triggers, 4th deduped by the escalation claim (PR #41 accounting). + for (let i = 0; i < 4; i++) { + await log.append(makeEvent('guard-dedup', now + i * 100_000)); + } + await new Promise((r) => setTimeout(r, 50)); + assert.equal(triggerEval.mock.callCount(), 1, 'dedup: only one trigger despite 4 events'); + }); +}); + +// --------------------------------------------------------------------------- +// sol R9 P1-1: Multi-owner isolation — red-green regression suite +// --------------------------------------------------------------------------- + +describe('F257 owner-scope isolation (sol R9 P1-1)', () => { + it('A=2 B=1 episodes: neither owner triggers (below threshold individually)', async () => { + // Owner A: 2 episodes, Owner B: 1 episode → total=3 but per-owner <3 + const eventsA = createEvents(2, 'guard-x', 'owner-a'); + const eventsB = createEvents(1, 'guard-x', 'owner-b'); + const { redis, guardRejectionLog } = await createFakeEventSource([...eventsA, ...eventsB]); + const triggerEval = mock.fn(async () => triggerSuccess()); + + const resultA = await checkGuardThreshold(makeEvent('guard-x', T + 5_000_000, 'owner-a'), { + redis, + guardRejectionLog, + triggerEval, + }); + const resultB = await checkGuardThreshold(makeEvent('guard-x', T + 5_000_000, 'owner-b'), { + redis, + guardRejectionLog, + triggerEval, + }); + + assert.equal(resultA.thresholdMet, false, 'owner-a with 2 episodes must NOT trigger'); + assert.equal(resultB.thresholdMet, false, 'owner-b with 1 episode must NOT trigger'); + assert.equal(triggerEval.mock.callCount(), 0, 'zero triggers when neither owner meets threshold'); + }); + + it('A=3 B=3 episodes: both trigger independently, claims isolated', async () => { + const eventsA = createEvents(3, 'guard-x', 'owner-a'); + const eventsB = createEvents(3, 'guard-x', 'owner-b'); + const { redis, guardRejectionLog } = await createFakeEventSource([...eventsA, ...eventsB]); + const triggerEval = mock.fn(async () => triggerSuccess()); + + const resultA = await checkGuardThreshold(makeEvent('guard-x', T + 5_000_000, 'owner-a'), { + redis, + guardRejectionLog, + triggerEval, + }); + const resultB = await checkGuardThreshold(makeEvent('guard-x', T + 5_000_000, 'owner-b'), { + redis, + guardRejectionLog, + triggerEval, + }); + + assert.equal(resultA.escalated, true, 'owner-a should escalate independently'); + assert.equal(resultB.escalated, true, 'owner-b should escalate independently'); + assert.equal(triggerEval.mock.callCount(), 2, 'both owners trigger eval'); + + // Claims are independent — A's claim doesn't suppress B + assert.ok(redis._store.has('guard-rejection:escalated:owner-a:guard-x'), 'owner-a claim exists'); + assert.ok(redis._store.has('guard-rejection:escalated:owner-b:guard-x'), 'owner-b claim exists'); + }); + + it('trigger receives each owner real ownerUserId, not synthetic', async () => { + const eventsA = createEvents(3, 'guard-y', 'owner-alpha'); + const eventsB = createEvents(3, 'guard-y', 'owner-beta'); + const { redis, guardRejectionLog } = await createFakeEventSource([...eventsA, ...eventsB]); + const triggerEval = mock.fn(async () => triggerSuccess()); + + await checkGuardThreshold(makeEvent('guard-y', T + 5_000_000, 'owner-alpha'), { + redis, + guardRejectionLog, + triggerEval, + }); + await checkGuardThreshold(makeEvent('guard-y', T + 5_000_000, 'owner-beta'), { + redis, + guardRejectionLog, + triggerEval, + }); + + assert.equal(triggerEval.mock.callCount(), 2); + assert.equal(triggerEval.mock.calls[0].arguments[0].userId, 'owner-alpha'); + assert.equal(triggerEval.mock.calls[1].arguments[0].userId, 'owner-beta'); + }); + + it('A escalated does NOT suppress B for 7 days (independent dedup keys)', async () => { + const eventsA = createEvents(4, 'guard-z', 'owner-a'); + const eventsB = createEvents(4, 'guard-z', 'owner-b'); + const { redis, guardRejectionLog } = await createFakeEventSource([...eventsA, ...eventsB]); + const triggerEval = mock.fn(async () => triggerSuccess()); + + // A escalates first + const resultA = await checkGuardThreshold(makeEvent('guard-z', T + 5_000_000, 'owner-a'), { + redis, + guardRejectionLog, + triggerEval, + }); + assert.equal(resultA.escalated, true); + + // A's 2nd check should be deduped + const resultA2 = await checkGuardThreshold(makeEvent('guard-z', T + 6_000_000, 'owner-a'), { + redis, + guardRejectionLog, + triggerEval, + }); + assert.equal(resultA2.alreadyEscalated, true, 'A deduped'); + + // B should still escalate despite A's claim existing + const resultB = await checkGuardThreshold(makeEvent('guard-z', T + 5_000_000, 'owner-b'), { + redis, + guardRejectionLog, + triggerEval, + }); + assert.equal(resultB.escalated, true, 'B must escalate independently of A'); + assert.equal(triggerEval.mock.callCount(), 2, 'exactly 2 triggers: A + B'); + }); + + it('countEpisodesPagewise receives ownerUserId from event (sol R10 P2-1 spy)', async () => { + // Spy on iterateWindow to verify the ownerUserId is passed through. + const events = createEvents(3, 'guard-spy', 'spy-owner'); + const { redis, guardRejectionLog } = await createFakeEventSource(events); + + const iterateCalls = []; + const originalIterateWindow = guardRejectionLog.iterateWindow.bind(guardRejectionLog); + guardRejectionLog.iterateWindow = async function* (opts, stats) { + iterateCalls.push(opts); + yield* originalIterateWindow(opts, stats); + }; + + const triggerEval = mock.fn(async () => triggerSuccess()); + await checkGuardThreshold(makeEvent('guard-spy', T + 5_000_000, 'spy-owner'), { + redis, + guardRejectionLog, + triggerEval, + }); + + assert.ok(iterateCalls.length >= 1, 'iterateWindow must be called'); + assert.equal( + iterateCalls[0].ownerUserId, + 'spy-owner', + 'ownerUserId must be forwarded to iterateWindow (sol R10 P2-1)', + ); + }); +}); diff --git a/packages/api/test/harness-eval/harness-ledger-attribution-refs.test.js b/packages/api/test/harness-eval/harness-ledger-attribution-refs.test.js new file mode 100644 index 0000000000..8e46484b94 --- /dev/null +++ b/packages/api/test/harness-eval/harness-ledger-attribution-refs.test.js @@ -0,0 +1,173 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; + +import { resolveA2aEvidenceBundle } from '../../dist/infrastructure/harness-eval/a2a/eval-a2a-artifact-resolver.js'; +import { createHarnessLedgerGeneratorAdapter } from '../../dist/infrastructure/harness-eval/publish-verdict/harness-ledger-generator-adapter.js'; + +// --------------------------------------------------------------------------- +// F257 V2/Phase B — per-finding attribution refs (producer side). +// +// PR #43 (merged fork/main) fixed HISTORICAL bundle assets whose verdict md +// referenced a bare `attribution:bundle//` — a ref the +// resolver cannot map to any bundled finding. V2 fixes the PRODUCER so the +// generator never emits that shape again (sol ruling msg 0001784470377310, +// terra independent concurrence; merged criteria msg 0001784470473525): +// 1. each findings[].id gets its own attribution ref +// 2. multi-guard bundles reference each `f257-guard-` finding +// 3. every ref independently resolvable +// 4. `:no-finding` anchor legal ONLY when findings=[] with +// noFindingRecord +// 5. regression: ALL refs in the committed bundle resolve +// +// Resolution authority: resolveA2aEvidenceBundle (fail-closed bundle gate) — +// these tests feed the md-declared refs back through the resolver, so +// "resolvable" is asserted by the production gate itself, not a re-encoding. +// --------------------------------------------------------------------------- + +const T = 1700000000000; + +function makeStoredSnapshot({ evalRunId, windowStartMs, windowEndMs, byGuard, byKind, totalEvents }) { + return { + evalRunId, + producedAt: new Date(T).toISOString(), + ownerUserId: 'user_1', + window: { startMs: windowStartMs, endMs: windowEndMs, durationHours: 168 }, + totalEvents, + byKind, + byGuard, + sampleAnchors: + totalEvents > 0 + ? [{ eventId: 'evt-1', kind: 'http_rate_limit', guardId: 'hold_ball_rate_limit', timestamp: T }] + : [], + howCounted: 'zset-window-scan', + }; +} + +function guardAgg(count, kinds, episodeCount, episodes = []) { + return { count, kinds, episodeCount, episodes }; +} + +async function generateBundle(storedSnapshot, packetId) { + const root = mkdtempSync(join(tmpdir(), 'f257-attr-refs-')); + mkdirSync(join(root, 'run-snapshots'), { recursive: true }); + writeFileSync(join(root, 'run-snapshots', `${storedSnapshot.evalRunId}.json`), JSON.stringify(storedSnapshot)); + + const generate = createHarnessLedgerGeneratorAdapter(); + const { verdictPath, bundleDir } = await generate( + { id: packetId, verdict: 'fix' }, + { + kind: 'prompt-segments', + windowStartMs: storedSnapshot.window.startMs, + windowEndMs: storedSnapshot.window.endMs, + evalRunId: storedSnapshot.evalRunId, + }, + { harnessFeedbackRoot: root, liveHarnessFeedbackRoot: root, ownerUserId: 'user_1' }, + ); + const verdictMd = readFileSync(verdictPath, 'utf8'); + return { root, bundleDir, verdictMd }; +} + +/** Extract `- attribution:bundle/...` evidence lines from the verdict markdown. */ +function extractAttributionRefs(verdictMd) { + return verdictMd + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.startsWith('- attribution:bundle/')) + .map((line) => line.slice(2)); +} + +describe('generator adapter — per-finding attribution refs (producer fix)', () => { + it('multi-guard bundle: verdict md declares one resolvable ref per finding, never a bare evalSnapshotId ref', async () => { + const packetId = 'test-attr-multi-1'; + const stored = makeStoredSnapshot({ + evalRunId: 'hlr-1700000000000-aaaa1111', + windowStartMs: T - 1000, + windowEndMs: T + 100_000, + totalEvents: 5, + byKind: { http_rate_limit: 4, route_decision_block: 1 }, + byGuard: { + hold_ball_rate_limit: guardAgg(4, ['http_rate_limit'], 1), + a2a_block_pingpong: guardAgg(1, ['route_decision_block'], 1), + }, + }); + + const { bundleDir, verdictMd } = await generateBundle(stored, packetId); + const mdRefs = extractAttributionRefs(verdictMd); + + // Criterion 1+2: one ref per finding, referencing each f257-guard-. + assert.equal(mdRefs.length, 2, 'verdict md must declare one attribution ref per finding'); + assert.ok( + mdRefs.includes(`attribution:bundle/${packetId}/f257-guard-hold_ball_rate_limit`), + 'hold_ball finding ref declared', + ); + assert.ok( + mdRefs.includes(`attribution:bundle/${packetId}/f257-guard-a2a_block_pingpong`), + 'a2a finding ref declared', + ); + + // The bare evalSnapshotId shape must be gone (PR #43 root cause). + assert.ok( + !mdRefs.some((r) => r.includes('harness-ledger-snapshot-') && !r.endsWith(':no-finding')), + 'bare evalSnapshotId attribution ref must not be produced when findings exist', + ); + + // Criterion 3+5: feed the md-declared refs through the production resolver — + // every declared ref must resolve against the committed bundle. + const resolved = resolveA2aEvidenceBundle({ + bundleDir, + verdictId: packetId, + attributionRefs: mdRefs, + }); + assert.equal(resolved.attributionRefs.length, 2, 'resolver derives the same two per-finding refs'); + assert.deepEqual(new Set(resolved.attributionRefs), new Set(mdRefs), 'md refs and resolver refs are the same set'); + }); + + it('zero-event bundle: no-finding anchor is declared and resolves (criterion 4)', async () => { + const packetId = 'test-attr-zero-1'; + const stored = makeStoredSnapshot({ + evalRunId: 'hlr-1700000000000-bbbb2222', + windowStartMs: T - 1000, + windowEndMs: T + 100_000, + totalEvents: 0, + byKind: {}, + byGuard: {}, + }); + + const { bundleDir, verdictMd } = await generateBundle(stored, packetId); + const mdRefs = extractAttributionRefs(verdictMd); + + assert.equal(mdRefs.length, 1, 'zero-event verdict declares exactly the no-finding ref'); + assert.ok(mdRefs[0].endsWith(':no-finding'), 'no-finding anchor shape'); + + const resolved = resolveA2aEvidenceBundle({ + bundleDir, + verdictId: packetId, + attributionRefs: mdRefs, + }); + assert.equal(resolved.attributionRefs.length, 1); + assert.ok(resolved.attributionRefs[0].endsWith(':no-finding')); + assert.ok(resolved.attributionReport.noFindingRecord, 'noFindingRecord present when findings=[]'); + }); + + it('bundle attribution findings carry episode accounting fields (provenance criteria join)', async () => { + const packetId = 'test-attr-episode-1'; + const stored = makeStoredSnapshot({ + evalRunId: 'hlr-1700000000000-cccc3333', + windowStartMs: T - 1000, + windowEndMs: T + 100_000, + totalEvents: 4, + byKind: { http_rate_limit: 4 }, + byGuard: { hold_ball_rate_limit: guardAgg(4, ['http_rate_limit'], 1) }, + }); + + const { bundleDir } = await generateBundle(stored, packetId); + const attribution = JSON.parse(readFileSync(join(bundleDir, 'attribution.json'), 'utf8')); + assert.equal(attribution.findings.length, 1); + assert.equal(attribution.findings[0].id, 'f257-guard-hold_ball_rate_limit'); + assert.equal(attribution.findings[0].rawEventCount, 4, 'finding carries rawEventCount'); + assert.equal(attribution.findings[0].episodeCount, 1, 'finding carries episodeCount (distinct incidents)'); + }); +}); diff --git a/packages/api/test/harness-eval/local-artifact-publisher.test.js b/packages/api/test/harness-eval/local-artifact-publisher.test.js new file mode 100644 index 0000000000..27dde5a8dc --- /dev/null +++ b/packages/api/test/harness-eval/local-artifact-publisher.test.js @@ -0,0 +1,431 @@ +import assert from 'node:assert/strict'; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterEach, describe, it } from 'node:test'; +import { loadEvalHubSummary } from '../../dist/infrastructure/harness-eval/hub/eval-hub-read-model.js'; +import { createLocalArtifactPublisher } from '../../dist/infrastructure/harness-eval/publish-verdict/local-artifact-publisher.js'; + +function makePacket(overrides = {}) { + return { + id: 'hlr-20260729-abcdef12', + domainId: 'eval:harness-ledger', + phenomenon: 'test phenomenon', + harnessUnderEval: { featureId: 'F257', componentId: 'ledger', name: 'Harness Ledger' }, + verdict: 'keep_observe', + ownerAsk: 'observe', + dailyTrend: {}, + rootCauseHypothesis: 'test', + evidencePacket: {}, + acceptanceReevalPlan: 'test', + counterarguments: 'none', + createdAt: new Date().toISOString(), + ...overrides, + }; +} + +function makeDomainRegistry(root) { + const dir = join(root, 'eval-domains'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'eval-harness-ledger.yaml'), + `--- +domainId: eval:harness-ledger +displayName: Harness Ledger +systemThreadId: thread_eval_harness_ledger +evalCat: + catId: codex + handle: "@codex" + model: gpt-5.6 +frequency: daily +sourceAdapter: harness-ledger +sourceRefsKind: prompt-segments +enabled: true +threadPolicy: + role: working-home + stateSot: registry + allowedContent: + - longitudinal-analysis + - verdict-discussion + - handoff-drafts +legacyScheduledTaskIds: [] +handoffTargetResolver: + featureId: F257 + ownerCatId: codex + threadLookup: feature-thread +sla: + acknowledgeHours: 24 + reevalWithinHours: 72 +`, + ); +} + +describe('createLocalArtifactPublisher', () => { + let artifactRoot; + + afterEach(() => { + if (artifactRoot) { + rmSync(artifactRoot, { recursive: true, force: true }); + artifactRoot = undefined; + } + }); + + it('atomically commits verdict.md and bundle/', async () => { + artifactRoot = mkdtempSync(join(tmpdir(), 'artifact-store-')); + const publisher = createLocalArtifactPublisher({ artifactRoot }); + const packet = makePacket(); + + const ref = await publisher.publishArtifact({ + packet, + sourceRefs: { kind: 'prompt-segments', windowStartMs: 1, windowEndMs: 2, evalRunId: packet.id }, + async generate(outputRoot) { + // Existing generators write into the legacy isolated-worktree layout: + // verdicts/.md and bundles// under docs/harness-feedback. + const verdictPath = join(outputRoot, 'verdicts', `${packet.id}.md`); + const bundleDir = join(outputRoot, 'bundles', packet.id); + mkdirSync(bundleDir, { recursive: true }); + mkdirSync(dirname(verdictPath), { recursive: true }); + writeFileSync(verdictPath, '# Verdict\n'); + writeFileSync(join(bundleDir, 'snapshot.json'), '{}'); + return { verdictPath, bundleDir }; + }, + }); + + assert.equal(existsSync(ref.verdictPath), true); + assert.equal(existsSync(ref.bundleDir), true); + assert.equal(existsSync(join(ref.bundleDir, 'snapshot.json')), true); + assert.equal(readFileSync(ref.verdictPath, 'utf8'), '# Verdict\n'); + assert.equal(ref.domainSlug, 'eval-harness-ledger'); + assert.equal(ref.artifactId, packet.id); + assert.match(ref.artifactUrl, /^artifact:\/\/eval-harness-ledger\/hlr-20260729-abcdef12$/); + }); + + it('rejects duplicate artifactId with artifact_already_exists', async () => { + artifactRoot = mkdtempSync(join(tmpdir(), 'artifact-store-')); + const publisher = createLocalArtifactPublisher({ artifactRoot }); + const packet = makePacket(); + const run = () => + publisher.publishArtifact({ + packet, + sourceRefs: { kind: 'prompt-segments', windowStartMs: 1, windowEndMs: 2, evalRunId: packet.id }, + async generate(outputRoot) { + const verdictPath = join(outputRoot, 'verdicts', `${packet.id}.md`); + const bundleDir = join(outputRoot, 'bundles', packet.id); + mkdirSync(bundleDir, { recursive: true }); + mkdirSync(dirname(verdictPath), { recursive: true }); + writeFileSync(verdictPath, '# Verdict\n'); + writeFileSync(join(bundleDir, 'snapshot.json'), '{}'); + return { verdictPath, bundleDir }; + }, + }); + + await run(); + await assert.rejects(run(), /artifact_already_exists/); + }); + + it('executes afterPublish exactly once after durable commit', async () => { + artifactRoot = mkdtempSync(join(tmpdir(), 'artifact-store-')); + const publisher = createLocalArtifactPublisher({ artifactRoot }); + const packet = makePacket(); + let afterPublishCalls = 0; + + await publisher.publishArtifact({ + packet, + sourceRefs: { kind: 'prompt-segments', windowStartMs: 1, windowEndMs: 2, evalRunId: packet.id }, + async generate(outputRoot) { + const verdictPath = join(outputRoot, 'verdicts', `${packet.id}.md`); + const bundleDir = join(outputRoot, 'bundles', packet.id); + mkdirSync(bundleDir, { recursive: true }); + mkdirSync(dirname(verdictPath), { recursive: true }); + writeFileSync(verdictPath, '# Verdict\n'); + return { + verdictPath, + bundleDir, + afterPublish() { + afterPublishCalls += 1; + }, + }; + }, + }); + + assert.equal(afterPublishCalls, 1); + }); + + it('cleans up staging directory when generator fails', async () => { + artifactRoot = mkdtempSync(join(tmpdir(), 'artifact-store-')); + const publisher = createLocalArtifactPublisher({ artifactRoot }); + const packet = makePacket(); + + await assert.rejects( + publisher.publishArtifact({ + packet, + sourceRefs: { kind: 'prompt-segments', windowStartMs: 1, windowEndMs: 2, evalRunId: packet.id }, + async generate() { + throw new Error('generator failed'); + }, + }), + /generator failed/, + ); + + const domainDir = join(artifactRoot, 'eval-harness-ledger'); + if (existsSync(domainDir)) { + const entries = readdirSync(domainDir); + assert.equal( + entries.some((name) => name.startsWith('.staging-')), + false, + 'staging dir must be removed', + ); + } + }); + + it('rolls back committed artifact when afterPublish fails', async () => { + artifactRoot = mkdtempSync(join(tmpdir(), 'artifact-store-')); + const publisher = createLocalArtifactPublisher({ artifactRoot }); + const packet = makePacket({ id: 'hlr-afterpublish-fail-001' }); + const finalDir = join(artifactRoot, 'eval-harness-ledger', packet.id); + + await assert.rejects( + publisher.publishArtifact({ + packet, + sourceRefs: { kind: 'prompt-segments', windowStartMs: 1, windowEndMs: 2, evalRunId: packet.id }, + async generate(outputRoot) { + const verdictPath = join(outputRoot, 'verdicts', `${packet.id}.md`); + const bundleDir = join(outputRoot, 'bundles', packet.id); + mkdirSync(bundleDir, { recursive: true }); + mkdirSync(dirname(verdictPath), { recursive: true }); + writeFileSync(verdictPath, '# Verdict\n'); + return { + verdictPath, + bundleDir, + afterPublish() { + throw new Error('writeback failed'); + }, + }; + }, + }), + /artifact_publish_rollback/, + ); + + assert.equal(existsSync(finalDir), false, 'artifact must be rolled back after afterPublish failure'); + }); + + it('preserves typed domain errors from afterPublish while rolling back', async () => { + artifactRoot = mkdtempSync(join(tmpdir(), 'artifact-store-')); + const publisher = createLocalArtifactPublisher({ artifactRoot }); + const packet = makePacket({ id: 'hlr-domain-error-001' }); + const finalDir = join(artifactRoot, 'eval-harness-ledger', packet.id); + + await assert.rejects( + publisher.publishArtifact({ + packet, + sourceRefs: { kind: 'prompt-segments', windowStartMs: 1, windowEndMs: 2, evalRunId: packet.id }, + async generate(outputRoot) { + const verdictPath = join(outputRoot, 'verdicts', `${packet.id}.md`); + const bundleDir = join(outputRoot, 'bundles', packet.id); + mkdirSync(bundleDir, { recursive: true }); + mkdirSync(dirname(verdictPath), { recursive: true }); + writeFileSync(verdictPath, '# Verdict\n'); + return { + verdictPath, + bundleDir, + afterPublish() { + throw new Error('invalid_episode_verdict_writeback: stale claim'); + }, + }; + }, + }), + /invalid_episode_verdict_writeback: stale claim/, + ); + + assert.equal(existsSync(finalDir), false, 'artifact must be rolled back after afterPublish domain error'); + }); + + it('normalizes concurrent duplicate publish race to artifact_already_exists', async () => { + artifactRoot = mkdtempSync(join(tmpdir(), 'artifact-store-')); + const publisher = createLocalArtifactPublisher({ artifactRoot }); + const packet = makePacket({ id: 'hlr-concurrent-001' }); + + const generate = async (outputRoot) => { + // Yield the event loop so both publishers pass the initial existsSync + // check before either reaches the atomic rename, forcing the OS-level + // EEXIST/ENOTEMPTY race path. + await new Promise((r) => setTimeout(r, 10)); + const verdictPath = join(outputRoot, 'verdicts', `${packet.id}.md`); + const bundleDir = join(outputRoot, 'bundles', packet.id); + mkdirSync(bundleDir, { recursive: true }); + mkdirSync(dirname(verdictPath), { recursive: true }); + writeFileSync(verdictPath, '# Verdict\n'); + return { verdictPath, bundleDir }; + }; + + const opts = { + packet, + sourceRefs: { kind: 'prompt-segments', windowStartMs: 1, windowEndMs: 2, evalRunId: packet.id }, + generate, + }; + + const [a, b] = await Promise.allSettled([publisher.publishArtifact(opts), publisher.publishArtifact(opts)]); + + const fulfilled = [a, b].filter((r) => r.status === 'fulfilled'); + const rejected = [a, b].filter((r) => r.status === 'rejected'); + + assert.equal(fulfilled.length, 1, 'exactly one concurrent publish must succeed'); + assert.equal(rejected.length, 1, 'exactly one concurrent publish must fail'); + assert.match( + rejected[0].reason instanceof Error ? rejected[0].reason.message : String(rejected[0].reason), + /artifact_already_exists/, + 'the loser must be normalized to artifact_already_exists', + ); + }); +}); + +describe('local artifact store + Eval Hub read-model', () => { + let tmp; + + afterEach(() => { + if (tmp) { + rmSync(tmp, { recursive: true, force: true }); + tmp = undefined; + } + }); + + it('loadEvalHubSummary surfaces artifact-store verdicts', async () => { + tmp = mkdtempSync(join(tmpdir(), 'eval-hub-artifact-')); + const harnessFeedbackRoot = join(tmp, 'docs', 'harness-feedback'); + const artifactStoreRoot = join(tmp, 'data', 'harness-feedback', 'artifacts'); + makeDomainRegistry(harnessFeedbackRoot); + + const publisher = createLocalArtifactPublisher({ artifactRoot: artifactStoreRoot }); + const packet = makePacket({ id: 'hlr-roundtrip-001' }); + await publisher.publishArtifact({ + packet, + sourceRefs: { kind: 'prompt-segments', windowStartMs: 1, windowEndMs: 2, evalRunId: packet.id }, + async generate(outputRoot) { + const verdictPath = join(outputRoot, 'verdicts', `${packet.id}.md`); + const bundleDir = join(outputRoot, 'bundles', packet.id); + mkdirSync(bundleDir, { recursive: true }); + mkdirSync(dirname(verdictPath), { recursive: true }); + writeFileSync( + verdictPath, + `--- +doc_kind: harness-feedback +feedback_type: live-verdict +domain_id: eval:harness-ledger +packet_id: ${packet.id} +--- + +# Verdict + +- Verdict: \`keep_observe\` +- Phenomenon: test +- Owner ask: observe +- Harness: F257/ledger (Harness Ledger) +- Re-eval: 2099-01-01T00:00:00.000Z + +Evidence: +- metric:test +`, + ); + const verdictId = packet.id; + const evalSnapshotId = 'eval-F257-2026-07-29'; + writeFileSync( + join(bundleDir, 'snapshot.json'), + JSON.stringify( + { + verdictId, + evalSnapshotId, + featureId: 'F257', + generatedAt: '2099-01-01T00:00:00.000Z', + window: { startMs: 1, endMs: 2, durationHours: 0 }, + components: [ + { + componentId: 'C1', + componentName: 'test component', + confidence: 'medium', + activationCounts: { 'test.metric': 1 }, + frictionCounts: {}, + }, + ], + }, + null, + 2, + ), + ); + writeFileSync( + join(bundleDir, 'attribution.json'), + JSON.stringify( + { + verdictId, + featureId: 'F257', + evalSnapshotId, + generatedAt: '2099-01-01T00:00:00.000Z', + findings: [ + { + id: 'F-001', + frictionSignal: { type: 'test', severity: 'low', confidence: 0.5 }, + attribution: { + primaryLayer: 'test-layer', + evidence: [ + { + type: 'counter', + anchor: 'C1/test.metric', + excerpt: 'test evidence', + }, + ], + }, + proposedAction: [ + { + action: 'observe', + target: 'test', + rationale: 'test', + }, + ], + }, + ], + }, + null, + 2, + ), + ); + writeFileSync( + join(bundleDir, 'provenance.json'), + JSON.stringify( + { + verdictId, + generatedAt: '2099-01-01T00:00:00.000Z', + rawInputs: [ + { + path: 'test-input', + sha256: '0000000000000000000000000000000000000000000000000000000000000000', + }, + ], + generator: { name: 'test', version: '1.0.0' }, + sanitizeRulesVersion: '1.0.0', + }, + null, + 2, + ), + ); + return { verdictPath, bundleDir }; + }, + }); + + const summary = loadEvalHubSummary({ + harnessFeedbackRoot, + artifactStoreRoot, + now: new Date('2099-01-01T00:00:00.000Z'), + }); + assert.equal(summary.items.length, 1); + const item = summary.items[0]; + assert.equal(item.id, packet.id); + assert.equal(item.verdict, 'keep_observe'); + assert.equal( + item.source.verdictPath, + 'data/harness-feedback/artifacts/eval-harness-ledger/hlr-roundtrip-001/docs/harness-feedback/verdicts/hlr-roundtrip-001.md', + ); + assert.equal( + item.source.bundleDir, + 'data/harness-feedback/artifacts/eval-harness-ledger/hlr-roundtrip-001/docs/harness-feedback/bundles/hlr-roundtrip-001', + ); + }); +}); diff --git a/packages/api/test/harness-eval/paw-feel-adapter.test.js b/packages/api/test/harness-eval/paw-feel-adapter.test.js index 2efcb25ba0..0cdab3e568 100644 --- a/packages/api/test/harness-eval/paw-feel-adapter.test.js +++ b/packages/api/test/harness-eval/paw-feel-adapter.test.js @@ -51,7 +51,15 @@ describe('PawFeelAdapter — Redis-backed pull', { skip: redisIsolationSkipReaso }); function seed({ thread, cat, ts, content }) { - return store.append({ userId: 'u1', catId: cat, content, mentions: [], timestamp: ts, threadId: thread }); + return store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, + userId: 'u1', + catId: cat, + content, + mentions: [], + timestamp: ts, + threadId: thread, + }); } it('采集时间窗内 marker → 结构化 signal(跨 thread/cat,字段正确)', async () => { @@ -148,6 +156,7 @@ describe('PawFeelAdapter — Redis-backed pull', { skip: redisIsolationSkipReaso const created = T0 - 5000; // 窗口前(raw timestamp) const delivered = T0 + 1000; // 窗口内(effective time) const m = await store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'u1', catId: 'opus-48', content: '[爪感差: rg 噪音]', @@ -171,6 +180,7 @@ describe('PawFeelAdapter — Redis-backed pull', { skip: redisIsolationSkipReaso // 格式(讨论时)不算真信号——author guard 跳过 catId===null。 it('P1-2: user-authored 引用 marker 格式不采集(author guard)', async () => { await store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u1', catId: null, content: '讨论格式:比如猫会写 [爪感差: rg 噪音太多]', @@ -179,6 +189,7 @@ describe('PawFeelAdapter — Redis-backed pull', { skip: redisIsolationSkipReaso threadId: 'th-1', }); const catMsg = await store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'u1', catId: 'opus-48', content: '[爪感差: hold_ball 卡]', @@ -205,6 +216,7 @@ describe('PawFeelAdapter — in-memory store path (cloud R3 P2)', () => { it('queued-delivered message 不重复不死循环(pageSize=1)', { timeout: 8000 }, async () => { const store = new MessageStore(); const m1 = store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'u1', catId: 'opus-48', content: '[爪感差: rg 噪音]', @@ -215,6 +227,7 @@ describe('PawFeelAdapter — in-memory store path (cloud R3 P2)', () => { }); store.markDelivered(m1.id, M0 + 1000); const m2 = store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'u1', catId: 'codex', content: '[爪感差: grep 慢]', diff --git a/packages/api/test/harness-eval/publish-verdict-capability-wakeup-owner-scope.test.js b/packages/api/test/harness-eval/publish-verdict-capability-wakeup-owner-scope.test.js index 49c36f7fbb..74a92b9183 100644 --- a/packages/api/test/harness-eval/publish-verdict-capability-wakeup-owner-scope.test.js +++ b/packages/api/test/harness-eval/publish-verdict-capability-wakeup-owner-scope.test.js @@ -6,6 +6,48 @@ import { after, before, describe, it } from 'node:test'; import { createCapabilityWakeupGeneratorAdapter } from '../../dist/infrastructure/harness-eval/publish-verdict/capability-wakeup-generator-adapter.js'; import { handlePublishVerdict } from '../../dist/infrastructure/harness-eval/publish-verdict/publish-verdict.js'; +const CW_DOMAIN_YAML = `domainId: eval:capability-wakeup +displayName: Capability Wakeup Eval +systemThreadId: thread_eval_capability_wakeup +evalCat: + catId: opus-47 + handle: "@opus47" + model: claude-opus-4-7 +frequency: weekly +sourceAdapter: capability-wakeup-eval +sourceRefsKind: capability-wakeup-trial-window +threadPolicy: + role: working-home + stateSot: registry + allowedContent: [longitudinal-analysis, verdict-discussion, handoff-drafts] +legacyScheduledTaskIds: [] +handoffTargetResolver: + featureId: F203 + ownerCatId: opus-47 + threadLookup: feature-thread +sla: + acknowledgeHours: 48 + reevalWithinHours: 168 +`; + +function buildCwArtifactPublisher(isoPath) { + return { + async publishArtifact({ packet, generate }) { + const outputRoot = join(isoPath, 'docs', 'harness-feedback'); + mkdirSync(join(outputRoot, 'eval-domains'), { recursive: true }); + writeFileSync(join(outputRoot, 'eval-domains', 'eval-capability-wakeup.yaml'), CW_DOMAIN_YAML); + const generated = await generate(outputRoot); + return { + artifactId: 'unreachable', + domainSlug: packet.domainId.replace(/:/g, '-'), + verdictPath: generated.verdictPath, + bundleDir: generated.bundleDir, + artifactUrl: 'unreachable', + }; + }, + }; +} + const root = mkdtempSync(join(tmpdir(), 'publish-verdict-cw-owner-')); before(() => { @@ -77,15 +119,10 @@ describe('handlePublishVerdict capability-wakeup owner scope', () => { }, }; const cwGenerator = createCapabilityWakeupGeneratorAdapter(provider); - const mockGitPublisher = { - async publishOnIsolatedWorktree(opts) { - await opts.stage(join(root, '..', 'cw-owner-iso')); - return { commitSha: 'unreachable', prUrl: 'unreachable' }; - }, - }; + const artifactPublisher = buildCwArtifactPublisher(join(root, '..', 'cw-owner-iso')); const result = await handlePublishVerdict( - { harnessFeedbackRoot: root, gitPublisher: mockGitPublisher, generator: cwGenerator }, + { harnessFeedbackRoot: root, artifactPublisher, generator: cwGenerator }, { packet: buildCwPacket(), domain: 'eval:capability-wakeup', diff --git a/packages/api/test/harness-eval/publish-verdict-capability-wakeup-strict-validation.test.js b/packages/api/test/harness-eval/publish-verdict-capability-wakeup-strict-validation.test.js index 912e13999f..94c8b68869 100644 --- a/packages/api/test/harness-eval/publish-verdict-capability-wakeup-strict-validation.test.js +++ b/packages/api/test/harness-eval/publish-verdict-capability-wakeup-strict-validation.test.js @@ -9,7 +9,7 @@ import { handlePublishVerdict } from '../../dist/infrastructure/harness-eval/pub * F192 Phase H 收尾 PR-2 R9 P1 (cloud): split from publish-verdict-capability-wakeup.test.js * to keep both files under AGENTS.md 350-line hard limit. * - * Covers handler-level strict validation BEFORE isolated worktree creation: + * Covers handler-level strict validation BEFORE artifact staging begins: * - cloud R8 P2: sourceRefs.kind ↔ packet.domainId cross-check (mismatch → 400) * - 砚砚 R1 PR-2 review P2: PR-2 wired window selectors; AC-F8 later allows omitted sessionIds * for unbiased window scan while trial-ids stays rejected until durable trial store exists. diff --git a/packages/api/test/harness-eval/publish-verdict-capability-wakeup.test.js b/packages/api/test/harness-eval/publish-verdict-capability-wakeup.test.js index 533e0075cd..2c3f255808 100644 --- a/packages/api/test/harness-eval/publish-verdict-capability-wakeup.test.js +++ b/packages/api/test/harness-eval/publish-verdict-capability-wakeup.test.js @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { after, before, describe, it } from 'node:test'; @@ -84,12 +84,25 @@ sla: mkdirSync(join(root, 'bundles'), { recursive: true }); } +function cleanupIsoStub(name) { + const stub = join(root, '..', name); + if (existsSync(stub)) { + rmSync(stub, { recursive: true, force: true }); + } +} + before(() => { seedRegistryAndDirs(); + // Legacy tests reuse fixed iso-stub paths; stale generator outputs from prior + // runs would trigger the duplicate-id guard. Clean before + after. + cleanupIsoStub('cw-e2e-iso-stub'); + cleanupIsoStub('cw-e2e-nofound-iso'); }); after(() => { rmSync(root, { recursive: true, force: true }); + cleanupIsoStub('cw-e2e-iso-stub'); + cleanupIsoStub('cw-e2e-nofound-iso'); }); function buildCwPacket(overrides = {}) { @@ -133,24 +146,7 @@ function buildClassifiedTrial() { }; } -describe('handlePublishVerdict end-to-end with capability-wakeup generator', () => { - it('happy path: handler dispatches to cw adapter, returns verdict path + commit/PR', async () => { - const provider = { resolve: async () => [buildClassifiedTrial()] }; - const cwGenerator = createCapabilityWakeupGeneratorAdapter(provider); - const mockGitPublisher = { - async publishOnIsolatedWorktree(opts) { - // Stage callback runs generator inside isolated worktree. - // For e2e, run stage against the LIVE root so generator can find registry + write artifacts. - await opts.stage(join(root, '..', 'cw-e2e-iso-stub')); - return { commitSha: 'cw-sha-1234', prUrl: 'https://github.com/zts212653/clowder-ai/pull/9000' }; - }, - }; - // Pre-create the isolated stub so cw generator's loadDomains() works - const isoStub = join(root, '..', 'cw-e2e-iso-stub'); - mkdirSync(join(isoStub, 'docs', 'harness-feedback', 'eval-domains'), { recursive: true }); - writeFileSync( - join(isoStub, 'docs', 'harness-feedback', 'eval-domains', 'eval-capability-wakeup.yaml'), - `domainId: eval:capability-wakeup +const CW_DOMAIN_YAML = `domainId: eval:capability-wakeup displayName: Capability Wakeup Eval systemThreadId: thread_eval_capability_wakeup evalCat: @@ -172,11 +168,42 @@ handoffTargetResolver: sla: acknowledgeHours: 48 reevalWithinHours: 168 -`, - ); +`; + +/** + * F257 / F192 sunset: ArtifactPublisher mock that seeds the eval-capability-wakeup + * registry into the output root so the cw adapter can loadDomains(). + */ +function buildCwArtifactPublisher(isoPath, { artifactId, artifactUrl } = {}) { + return { + async publishArtifact({ packet, generate }) { + const outputRoot = join(isoPath, 'docs', 'harness-feedback'); + mkdirSync(join(outputRoot, 'eval-domains'), { recursive: true }); + writeFileSync(join(outputRoot, 'eval-domains', 'eval-capability-wakeup.yaml'), CW_DOMAIN_YAML); + const generated = await generate(outputRoot); + return { + artifactId: artifactId ?? packet.id, + domainSlug: packet.domainId.replace(/:/g, '-'), + verdictPath: generated.verdictPath, + bundleDir: generated.bundleDir, + artifactUrl: artifactUrl ?? `artifact://${packet.domainId}/${packet.id}`, + }; + }, + }; +} + +describe('handlePublishVerdict end-to-end with capability-wakeup generator', () => { + it('happy path: handler dispatches to cw adapter and returns durable artifact refs', async () => { + const provider = { resolve: async () => [buildClassifiedTrial()] }; + const cwGenerator = createCapabilityWakeupGeneratorAdapter(provider); + const isoStub = join(root, '..', 'cw-e2e-iso-stub'); + const artifactPublisher = buildCwArtifactPublisher(isoStub, { + artifactId: 'cw-sha-1234', + artifactUrl: 'artifact://eval-capability-wakeup/cw-artifact-1234', + }); const result = await handlePublishVerdict( - { harnessFeedbackRoot: root, gitPublisher: mockGitPublisher, generator: cwGenerator }, + { harnessFeedbackRoot: root, artifactPublisher, generator: cwGenerator }, { packet: buildCwPacket(), domain: 'eval:capability-wakeup', @@ -193,11 +220,11 @@ sla: ); assert.ok(!('error' in result), `expected success, got: ${JSON.stringify(result)}`); - assert.equal(result.commitSha, 'cw-sha-1234'); - assert.equal(result.prUrl, 'https://github.com/zts212653/clowder-ai/pull/9000'); - // 砚砚 R12 P2 cloud: repo-relative paths (deterministic from packet.id) - assert.equal(result.verdictPath, 'docs/harness-feedback/verdicts/vhp-cw-e2e-test.md'); - assert.equal(result.bundleDir, 'docs/harness-feedback/bundles/vhp-cw-e2e-test'); + assert.equal(result.artifactId, 'cw-sha-1234'); + assert.equal(result.artifactUrl, 'artifact://eval-capability-wakeup/cw-artifact-1234'); + // F257 / F192 sunset: ArtifactPublisher returns absolute store paths; assert suffix. + assert.match(result.verdictPath, /verdicts\/vhp-cw-e2e-test\.md$/); + assert.match(result.bundleDir, /bundles\/vhp-cw-e2e-test$/); // cleanup rmSync(isoStub, { recursive: true, force: true }); @@ -216,16 +243,11 @@ sla: }, }; const cwGenerator = createCapabilityWakeupGeneratorAdapter(provider); - const mockGitPublisher = { - async publishOnIsolatedWorktree(opts) { - await opts.stage(join(root, '..', 'cw-e2e-nofound-iso')); - return { commitSha: 'unreachable', prUrl: 'unreachable' }; - }, - }; - mkdirSync(join(root, '..', 'cw-e2e-nofound-iso', 'docs', 'harness-feedback'), { recursive: true }); + const noFoundIso = join(root, '..', 'cw-e2e-nofound-iso'); + const artifactPublisher = buildCwArtifactPublisher(noFoundIso); const result = await handlePublishVerdict( - { harnessFeedbackRoot: root, gitPublisher: mockGitPublisher, generator: cwGenerator }, + { harnessFeedbackRoot: root, artifactPublisher, generator: cwGenerator }, { packet: buildCwPacket({ id: 'vhp-cw-nofound' }), domain: 'eval:capability-wakeup', @@ -251,16 +273,10 @@ sla: it('returns 404 no_trials_in_window when provider yields zero trials (PR-2 4xx mapping)', async () => { const emptyProvider = { resolve: async () => [] }; const cwGenerator = createCapabilityWakeupGeneratorAdapter(emptyProvider); - const mockGitPublisher = { - async publishOnIsolatedWorktree(opts) { - await opts.stage(join(root, '..', 'cw-e2e-empty2-iso')); - return { commitSha: 'unreachable', prUrl: 'unreachable' }; - }, - }; - mkdirSync(join(root, '..', 'cw-e2e-empty2-iso', 'docs', 'harness-feedback'), { recursive: true }); + const artifactPublisher = buildCwArtifactPublisher(join(root, '..', 'cw-e2e-empty2-iso')); const result = await handlePublishVerdict( - { harnessFeedbackRoot: root, gitPublisher: mockGitPublisher, generator: cwGenerator }, + { harnessFeedbackRoot: root, artifactPublisher, generator: cwGenerator }, { packet: buildCwPacket({ id: 'vhp-cw-empty2' }), domain: 'eval:capability-wakeup', @@ -312,16 +328,10 @@ sla: it('returns 404 when cw provider yields zero trials (no_trials_in_window propagates)', async () => { const emptyProvider = { resolve: async () => [] }; const cwGenerator = createCapabilityWakeupGeneratorAdapter(emptyProvider); - const mockGitPublisher = { - async publishOnIsolatedWorktree(opts) { - await opts.stage(join(root, '..', 'cw-e2e-empty-iso')); - return { commitSha: 'unreachable', prUrl: 'unreachable' }; - }, - }; - mkdirSync(join(root, '..', 'cw-e2e-empty-iso', 'docs', 'harness-feedback'), { recursive: true }); + const artifactPublisher = buildCwArtifactPublisher(join(root, '..', 'cw-e2e-empty-iso')); const result = await handlePublishVerdict( - { harnessFeedbackRoot: root, gitPublisher: mockGitPublisher, generator: cwGenerator }, + { harnessFeedbackRoot: root, artifactPublisher, generator: cwGenerator }, { packet: buildCwPacket({ id: 'vhp-cw-empty' }), domain: 'eval:capability-wakeup', diff --git a/packages/api/test/harness-eval/publish-verdict-fixtures.js b/packages/api/test/harness-eval/publish-verdict-fixtures.js index ad300bb0fa..80b611e1e9 100644 --- a/packages/api/test/harness-eval/publish-verdict-fixtures.js +++ b/packages/api/test/harness-eval/publish-verdict-fixtures.js @@ -1,8 +1,12 @@ /** - * F192 Phase H publish-verdict shared test fixtures. + * F192 Phase H / F257 sunset publish-verdict shared test fixtures. * Extracted from publish-verdict.test.js per AGENTS.md 350-line hard limit. */ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join, resolve } from 'node:path'; + /** * Build a valid VerdictHandoffPacket with override-able fields. * Mirrors verdictHandoffPacketSchema shape; tests override specific fields @@ -40,3 +44,83 @@ export function buildPacket(overrides = {}) { ...overrides, }; } + +/** + * F257 / F192 sunset: mock ArtifactPublisher for unit tests. + * + * Replaces the deprecated GitPublisher mock. It creates a temporary output root, + * invokes the generator callback, optionally runs side effects, and returns a + * stable ArtifactRef. Tests can simulate failures via `failWith` (throws before + * generator), `failAfterGenerate` (throws after generator, before afterPublish), + * or `duplicateIds` (throws `artifact_already_exists:` when matched). + * + * @param {object} [opts] + * @param {string} [opts.artifactId] - returned artifactId (defaults to packet.id) + * @param {string} [opts.domainSlug] - returned domainSlug (defaults to sanitized packet.domainId) + * @param {string} [opts.artifactUrl] - returned artifactUrl + * @param {Set} [opts.duplicateIds] - ids that should trigger artifact_already_exists + * @param {string} [opts.failWith] - error message thrown before invoking generator + * @param {string} [opts.failAfterGenerate] - error message thrown after generator but before afterPublish + * @param {Function} [opts.beforePublish] - hook called after generator, before afterPublish + * @param {Function} [opts.afterPublish] - hook called after afterPublish + */ +export function createMockArtifactPublisher(opts = {}) { + return { + async publishArtifact({ packet, generate }) { + if (opts.failWith) { + throw new Error(opts.failWith); + } + if (opts.duplicateIds?.has(packet.id)) { + throw new Error(`artifact_already_exists:${packet.id}`); + } + + const tmpRoot = mkdtempSync(join(tmpdir(), `mock-artifact-${packet.id}-`)); + try { + const generated = await generate(tmpRoot); + + // Some legacy generators return absolute paths under the live root or a + // stub worktree. For the mock, normalize them to live under tmpRoot so + // downstream assertions can read the files if they need to. + const verdictPath = generated.verdictPath.startsWith(tmpRoot) + ? generated.verdictPath + : resolve(tmpRoot, basename(generated.verdictPath)); + const bundleDir = generated.bundleDir.startsWith(tmpRoot) + ? generated.bundleDir + : resolve(tmpRoot, basename(generated.bundleDir)); + + mkdirSync(dirname(verdictPath), { recursive: true }); + mkdirSync(bundleDir, { recursive: true }); + if (!existsSync(verdictPath)) { + writeFileSync(verdictPath, `# Mock verdict for ${packet.id}\n`); + } + + if (opts.beforePublish) { + await opts.beforePublish({ packet, generated, tmpRoot, verdictPath, bundleDir }); + } + + if (opts.failAfterGenerate) { + throw new Error(opts.failAfterGenerate); + } + + if (generated.afterPublish) { + await generated.afterPublish(); + } + + if (opts.afterPublish) { + await opts.afterPublish({ packet, generated, tmpRoot, verdictPath, bundleDir }); + } + + return { + artifactId: opts.artifactId ?? packet.id, + domainSlug: opts.domainSlug ?? packet.domainId.replace(/:/g, '-'), + verdictPath, + bundleDir, + artifactUrl: opts.artifactUrl ?? `artifact://${packet.domainId}/${packet.id}`, + }; + } catch (err) { + rmSync(tmpRoot, { recursive: true, force: true }); + throw err; + } + }, + }; +} diff --git a/packages/api/test/harness-eval/publish-verdict-friction.test.js b/packages/api/test/harness-eval/publish-verdict-friction.test.js index cde85c27b3..9bc17857b6 100644 --- a/packages/api/test/harness-eval/publish-verdict-friction.test.js +++ b/packages/api/test/harness-eval/publish-verdict-friction.test.js @@ -8,6 +8,30 @@ import { handlePublishVerdict } from '../../dist/infrastructure/harness-eval/pub import { setupHarnessFeedback } from './eval-manual-trigger-fixtures.js'; import { buildPacket } from './publish-verdict-fixtures.js'; +/** + * F257 / F192 sunset: custom ArtifactPublisher mock that seeds the eval-friction + * registry into the temporary output root before invoking the generator, so the + * adapter's loadDomains() call succeeds and files can be inspected after publish. + */ +function createFrictionArtifactPublisher(isoPath, { artifactId, artifactUrl } = {}) { + return { + async publishArtifact({ packet, generate }) { + rmSync(isoPath, { recursive: true, force: true }); + const outputRoot = join(isoPath, 'docs', 'harness-feedback'); + mkdirSync(join(outputRoot, 'eval-domains'), { recursive: true }); + writeFileSync(join(outputRoot, 'eval-domains', 'eval-friction.yaml'), FRICTION_YAML); + const generated = await generate(outputRoot); + return { + artifactId: artifactId ?? packet.id, + domainSlug: packet.domainId.replace(/:/g, '-'), + verdictPath: generated.verdictPath, + bundleDir: generated.bundleDir, + artifactUrl: artifactUrl ?? `artifact://${packet.domainId}/${packet.id}`, + }; + }, + }; +} + /** * F245 Phase C PR1b — publish_verdict eval:friction end-to-end test (L4). * @@ -18,7 +42,7 @@ import { buildPacket } from './publish-verdict-fixtures.js'; * 'friction-rollup-snapshot' for eval:friction (and rejects mismatches) * - Adapter resolves a rollup via provider port → writes * snapshot.json / attribution.json / provenance.json + raw report + verdict.md - * inside the isolated worktree + * inside the artifact staging root * - 501 still returned when domain has no generator wired * * NOTE: setupHarnessFeedback seeds 5 domains WITHOUT friction, so this test @@ -121,24 +145,18 @@ const SELECTOR = { }; describe('handlePublishVerdict end-to-end with eval:friction generator', () => { - it('happy path: handler dispatches to friction adapter, returns verdict path + commit/PR', async () => { + it('happy path: handler dispatches to friction adapter and returns durable artifact refs', async () => { const provider = { resolve: async () => buildRollupInput({ clusters: 2 }) }; const generator = createFrictionGeneratorAdapter(provider); - let isoStub; - const mockGitPublisher = { - async publishOnIsolatedWorktree(opts) { - isoStub = join(root, '..', 'friction-e2e-iso-stub'); - rmSync(isoStub, { recursive: true, force: true }); - mkdirSync(join(isoStub, 'docs', 'harness-feedback', 'eval-domains'), { recursive: true }); - writeFileSync(join(isoStub, 'docs', 'harness-feedback', 'eval-domains', 'eval-friction.yaml'), FRICTION_YAML); - await opts.stage(isoStub); - return { commitSha: 'friction-sha-1234', prUrl: 'https://github.com/zts212653/clowder-ai/pull/9200' }; - }, - }; + const isoStub = join(root, '..', 'friction-e2e-iso-stub'); + const artifactPublisher = createFrictionArtifactPublisher(isoStub, { + artifactId: 'friction-sha-1234', + artifactUrl: 'artifact://eval-friction/friction-artifact-1234', + }); const result = await handlePublishVerdict( - { harnessFeedbackRoot: root, gitPublisher: mockGitPublisher, generator }, + { harnessFeedbackRoot: root, artifactPublisher, generator }, { packet: buildFrictionPacket(), domain: 'eval:friction', @@ -148,9 +166,10 @@ describe('handlePublishVerdict end-to-end with eval:friction generator', () => { ); assert.ok(!('error' in result), `expected success, got: ${JSON.stringify(result)}`); - assert.equal(result.commitSha, 'friction-sha-1234'); - assert.equal(result.verdictPath, 'docs/harness-feedback/verdicts/vhp-friction-e2e-test.md'); - assert.equal(result.bundleDir, 'docs/harness-feedback/bundles/vhp-friction-e2e-test'); + assert.equal(result.artifactId, 'friction-sha-1234'); + assert.equal(result.artifactUrl, 'artifact://eval-friction/friction-artifact-1234'); + assert.match(result.verdictPath, /verdicts\/vhp-friction-e2e-test\.md$/); + assert.match(result.bundleDir, /bundles\/vhp-friction-e2e-test$/); const isoBundle = join(isoStub, 'docs', 'harness-feedback', 'bundles', 'vhp-friction-e2e-test'); assert.ok(existsSync(join(isoBundle, 'snapshot.json')), 'snapshot.json must be written'); diff --git a/packages/api/test/harness-eval/publish-verdict-memory.test.js b/packages/api/test/harness-eval/publish-verdict-memory.test.js index 95b1dcfafa..0f218c234e 100644 --- a/packages/api/test/harness-eval/publish-verdict-memory.test.js +++ b/packages/api/test/harness-eval/publish-verdict-memory.test.js @@ -8,6 +8,33 @@ import { handlePublishVerdict } from '../../dist/infrastructure/harness-eval/pub import { setupHarnessFeedback } from './eval-manual-trigger-fixtures.js'; import { buildPacket } from './publish-verdict-fixtures.js'; +/** + * F257 / F192 sunset: custom ArtifactPublisher mock that seeds the eval-memory + * registry into the temporary output root before invoking the generator, so the + * adapter's loadDomains() call succeeds and files can be inspected after publish. + */ +function createMemoryArtifactPublisher(isoPath, { artifactId, artifactUrl } = {}) { + return { + async publishArtifact({ packet, generate }) { + rmSync(isoPath, { recursive: true, force: true }); + const outputRoot = join(isoPath, 'docs', 'harness-feedback'); + mkdirSync(join(outputRoot, 'eval-domains'), { recursive: true }); + writeFileSync( + join(outputRoot, 'eval-domains', 'eval-memory.yaml'), + readFileSync(join(root, 'eval-domains', 'eval-memory.yaml'), 'utf8'), + ); + const generated = await generate(outputRoot); + return { + artifactId: artifactId ?? packet.id, + domainSlug: packet.domainId.replace(/:/g, '-'), + verdictPath: generated.verdictPath, + bundleDir: generated.bundleDir, + artifactUrl: artifactUrl ?? `artifact://${packet.domainId}/${packet.id}`, + }; + }, + }; +} + /** * F192 publish_verdict eval:memory wire-up — end-to-end test. * @@ -18,7 +45,7 @@ import { buildPacket } from './publish-verdict-fixtures.js'; * 'memory-recall-snapshot' for eval:memory * - Adapter resolves metrics via provider port → writes * snapshot.json / attribution.json / provenance.json + raw inputs + - * verdict.md inside isolated worktree + * verdict.md inside the artifact staging root * - Provider failure modes (no_metrics_in_window, provider throws) * map to 4xx, not 500 generator_failed * - 501 still returned when domain has no generator wired @@ -27,12 +54,21 @@ import { buildPacket } from './publish-verdict-fixtures.js'; /** @type {string} */ let root; +function cleanupIsoStub(name) { + const stub = join(root, '..', name); + if (existsSync(stub)) { + rmSync(stub, { recursive: true, force: true }); + } +} + before(() => { root = setupHarnessFeedback(); + cleanupIsoStub('mem-e2e-iso-stub'); }); after(() => { rmSync(root, { recursive: true, force: true }); + cleanupIsoStub('mem-e2e-iso-stub'); }); function buildMemoryPacket(overrides = {}) { @@ -95,7 +131,7 @@ function buildLibraryHealth(overrides = {}) { } describe('handlePublishVerdict end-to-end with eval:memory generator', () => { - it('happy path: handler dispatches to memory adapter, returns verdict path + commit/PR', async () => { + it('happy path: handler dispatches to memory adapter and returns durable artifact refs', async () => { const provider = { resolve: async () => ({ recallMetrics: buildRecallMetrics(), @@ -104,28 +140,14 @@ describe('handlePublishVerdict end-to-end with eval:memory generator', () => { }; const memGenerator = createMemoryGeneratorAdapter(provider); - /** @type {string} */ - let isoStub; - const mockGitPublisher = { - async publishOnIsolatedWorktree(opts) { - isoStub = join(root, '..', 'mem-e2e-iso-stub'); - // Mirror the registry into isolated worktree so loadDomains() works - mkdirSync(join(isoStub, 'docs', 'harness-feedback', 'eval-domains'), { recursive: true }); - writeFileSync( - join(isoStub, 'docs', 'harness-feedback', 'eval-domains', 'eval-memory.yaml'), - readFileSync(join(root, 'eval-domains', 'eval-memory.yaml'), 'utf8'), - ); - const stageResult = await opts.stage(isoStub); - return { - commitSha: 'mem-sha-1234', - prUrl: 'https://github.com/zts212653/clowder-ai/pull/9100', - stageResult, - }; - }, - }; + const isoStub = join(root, '..', 'mem-e2e-iso-stub'); + const artifactPublisher = createMemoryArtifactPublisher(isoStub, { + artifactId: 'mem-artifact-1234', + artifactUrl: 'artifact://eval-memory/vhp-mem-e2e-test', + }); const result = await handlePublishVerdict( - { harnessFeedbackRoot: root, gitPublisher: mockGitPublisher, generator: memGenerator }, + { harnessFeedbackRoot: root, artifactPublisher, generator: memGenerator }, { packet: buildMemoryPacket(), domain: 'eval:memory', @@ -138,12 +160,12 @@ describe('handlePublishVerdict end-to-end with eval:memory generator', () => { ); assert.ok(!('error' in result), `expected success, got: ${JSON.stringify(result)}`); - assert.equal(result.commitSha, 'mem-sha-1234'); - assert.equal(result.prUrl, 'https://github.com/zts212653/clowder-ai/pull/9100'); - assert.equal(result.verdictPath, 'docs/harness-feedback/verdicts/vhp-mem-e2e-test.md'); - assert.equal(result.bundleDir, 'docs/harness-feedback/bundles/vhp-mem-e2e-test'); + assert.equal(result.artifactId, 'mem-artifact-1234'); + assert.equal(result.artifactUrl, 'artifact://eval-memory/vhp-mem-e2e-test'); + assert.match(result.verdictPath, /verdicts\/vhp-mem-e2e-test\.md$/); + assert.match(result.bundleDir, /bundles\/vhp-mem-e2e-test$/); - // Verify generator wrote bundle artifacts inside isolated worktree + // Verify generator wrote bundle artifacts inside artifact staging const isoBundle = join(isoStub, 'docs', 'harness-feedback', 'bundles', 'vhp-mem-e2e-test'); assert.ok(existsSync(join(isoBundle, 'snapshot.json')), 'snapshot.json must be written'); assert.ok(existsSync(join(isoBundle, 'attribution.json')), 'attribution.json must be written'); @@ -188,24 +210,14 @@ describe('handlePublishVerdict end-to-end with eval:memory generator', () => { }; const memGenerator = createMemoryGeneratorAdapter(provider); - /** @type {string} */ - let isoStub; - const mockGitPublisher = { - async publishOnIsolatedWorktree(opts) { - isoStub = join(root, '..', 'mem-e2e-actionable-iso'); - rmSync(isoStub, { recursive: true, force: true }); // idempotent — clean leftover from prior runs - mkdirSync(join(isoStub, 'docs', 'harness-feedback', 'eval-domains'), { recursive: true }); - writeFileSync( - join(isoStub, 'docs', 'harness-feedback', 'eval-domains', 'eval-memory.yaml'), - readFileSync(join(root, 'eval-domains', 'eval-memory.yaml'), 'utf8'), - ); - await opts.stage(isoStub); - return { commitSha: 'mem-actionable-sha', prUrl: 'https://github.com/zts212653/clowder-ai/pull/9101' }; - }, - }; + const isoStub = join(root, '..', 'mem-e2e-actionable-iso'); + const artifactPublisher = createMemoryArtifactPublisher(isoStub, { + artifactId: 'mem-actionable-sha', + artifactUrl: 'artifact://eval-memory/mem-actionable-artifact', + }); const result = await handlePublishVerdict( - { harnessFeedbackRoot: root, gitPublisher: mockGitPublisher, generator: memGenerator }, + { harnessFeedbackRoot: root, artifactPublisher, generator: memGenerator }, { packet: buildMemoryPacket({ id: 'vhp-mem-actionable-fix', @@ -234,7 +246,7 @@ describe('handlePublishVerdict end-to-end with eval:memory generator', () => { // (Cloud Codex R5 P1: pre-fix, this hits resolveA2aEvidenceBundle's // 'attribution finding must include at least one bundled component evidence anchor'.) assert.ok(!('error' in result), `expected success, got: ${JSON.stringify(result)}`); - assert.equal(result.commitSha, 'mem-actionable-sha'); + assert.equal(result.artifactId, 'mem-actionable-sha'); // Verify attribution.json findings carry component-prefixed anchors const isoBundle = join(isoStub, 'docs', 'harness-feedback', 'bundles', 'vhp-mem-actionable-fix'); @@ -272,24 +284,14 @@ describe('handlePublishVerdict end-to-end with eval:memory generator', () => { }; const memGenerator = createMemoryGeneratorAdapter(provider); - /** @type {string} */ - let isoStub; - const mockGitPublisher = { - async publishOnIsolatedWorktree(opts) { - isoStub = join(root, '..', 'mem-e2e-f188-iso'); - rmSync(isoStub, { recursive: true, force: true }); - mkdirSync(join(isoStub, 'docs', 'harness-feedback', 'eval-domains'), { recursive: true }); - writeFileSync( - join(isoStub, 'docs', 'harness-feedback', 'eval-domains', 'eval-memory.yaml'), - readFileSync(join(root, 'eval-domains', 'eval-memory.yaml'), 'utf8'), - ); - await opts.stage(isoStub); - return { commitSha: 'mem-f188-sha', prUrl: 'https://github.com/zts212653/clowder-ai/pull/9102' }; - }, - }; + const isoStub = join(root, '..', 'mem-e2e-f188-iso'); + const artifactPublisher = createMemoryArtifactPublisher(isoStub, { + artifactId: 'mem-f188-sha', + artifactUrl: 'artifact://eval-memory/mem-f188-artifact', + }); const result = await handlePublishVerdict( - { harnessFeedbackRoot: root, gitPublisher: mockGitPublisher, generator: memGenerator }, + { harnessFeedbackRoot: root, artifactPublisher, generator: memGenerator }, { // packet targets F188 (library health finding) — domain default is F200 but // resolveHandoffFeatureId in adapter properly routes F188/* findings to F188. @@ -310,7 +312,7 @@ describe('handlePublishVerdict end-to-end with eval:memory generator', () => { // Cloud Codex R9 P1: pre-fix, my generator guard forced packet.featureId === F200 // and rejected F188 — broke the adapter's existing cross-feature handoff contract. assert.ok(!('error' in result), `expected success, got: ${JSON.stringify(result)}`); - assert.equal(result.commitSha, 'mem-f188-sha'); + assert.equal(result.artifactId, 'mem-f188-sha'); // Verify snapshot + attribution reflect packet's actual F188 feature, not F200 default const isoBundle = join(isoStub, 'docs', 'harness-feedback', 'bundles', 'vhp-mem-f188-cross-feature'); @@ -337,23 +339,13 @@ describe('handlePublishVerdict end-to-end with eval:memory generator', () => { }), }; const memGenerator = createMemoryGeneratorAdapter(provider); - const mockGitPublisher = { - async publishOnIsolatedWorktree(opts) { - const isoStub = join(root, '..', 'mem-e2e-invalid-fid-iso'); - rmSync(isoStub, { recursive: true, force: true }); - mkdirSync(join(isoStub, 'docs', 'harness-feedback', 'eval-domains'), { recursive: true }); - writeFileSync( - join(isoStub, 'docs', 'harness-feedback', 'eval-domains', 'eval-memory.yaml'), - readFileSync(join(root, 'eval-domains', 'eval-memory.yaml'), 'utf8'), - ); - await opts.stage(isoStub); - rmSync(isoStub, { recursive: true, force: true }); - return { commitSha: 'unreachable', prUrl: 'unreachable' }; - }, - }; + const artifactPublisher = createMemoryArtifactPublisher(join(root, '..', 'mem-e2e-invalid-fid-iso'), { + artifactId: 'unreachable', + artifactUrl: 'unreachable', + }); const result = await handlePublishVerdict( - { harnessFeedbackRoot: root, gitPublisher: mockGitPublisher, generator: memGenerator }, + { harnessFeedbackRoot: root, artifactPublisher, generator: memGenerator }, { packet: buildMemoryPacket({ id: 'vhp-mem-invalid-fid', @@ -461,23 +453,14 @@ describe('handlePublishVerdict end-to-end with eval:memory generator', () => { }; const memGenerator = createMemoryGeneratorAdapter(emptyProvider); - /** @type {string} */ - let isoStub; - const mockGitPublisher = { - async publishOnIsolatedWorktree(opts) { - isoStub = join(root, '..', 'mem-e2e-empty-iso'); - mkdirSync(join(isoStub, 'docs', 'harness-feedback', 'eval-domains'), { recursive: true }); - writeFileSync( - join(isoStub, 'docs', 'harness-feedback', 'eval-domains', 'eval-memory.yaml'), - readFileSync(join(root, 'eval-domains', 'eval-memory.yaml'), 'utf8'), - ); - await opts.stage(isoStub); - return { commitSha: 'unreachable', prUrl: 'unreachable' }; - }, - }; + const isoStub = join(root, '..', 'mem-e2e-empty-iso'); + const artifactPublisher = createMemoryArtifactPublisher(isoStub, { + artifactId: 'unreachable', + artifactUrl: 'unreachable', + }); const result = await handlePublishVerdict( - { harnessFeedbackRoot: root, gitPublisher: mockGitPublisher, generator: memGenerator }, + { harnessFeedbackRoot: root, artifactPublisher, generator: memGenerator }, { packet: buildMemoryPacket({ id: 'vhp-mem-empty' }), domain: 'eval:memory', diff --git a/packages/api/test/harness-eval/publish-verdict-pipeline.test.js b/packages/api/test/harness-eval/publish-verdict-pipeline.test.js deleted file mode 100644 index 593fbb16e7..0000000000 --- a/packages/api/test/harness-eval/publish-verdict-pipeline.test.js +++ /dev/null @@ -1,244 +0,0 @@ -import assert from 'node:assert/strict'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { resolve as resolvePath } from 'node:path'; -import { after, before, describe, it } from 'node:test'; - -import { handlePublishVerdict } from '../../dist/infrastructure/harness-eval/publish-verdict/publish-verdict.js'; -import { setupHarnessFeedback } from './eval-manual-trigger-fixtures.js'; -import { buildPacket } from './publish-verdict-fixtures.js'; - -/** - * 砚砚 R17 P1 cloud: snapshots/ + attributions/ are GITIGNORED — raw evidence - * lives ONLY in LIVE checkout. R7's "seed isolated worktree" assumption was wrong. - * Tests now seed evidence into LIVE root (handler's deps.harnessFeedbackRoot); - * stage callback resolves LIVE and copies to isolated for generator to read. - */ -function seedLiveEvidence(liveRoot, snapName, attrName) { - mkdirSync(resolvePath(liveRoot, 'snapshots'), { recursive: true }); - mkdirSync(resolvePath(liveRoot, 'attributions'), { recursive: true }); - if (snapName) writeFileSync(resolvePath(liveRoot, 'snapshots', snapName), 'fake snap\n'); - if (attrName) writeFileSync(resolvePath(liveRoot, 'attributions', attrName), 'fake attr\n'); -} - -/** Empty isolated worktree (mock gitPublisher's tmp dir; stage callback copies into it). */ -function makeEmptyIsolatedWorktree() { - return mkdtempSync(`${tmpdir()}/phase-h-pipeline-iso-`); -} - -/** - * F192 Phase H AC-H2: GitPublisher isolated-worktree pipeline tests. - * Split from publish-verdict.test.js per AGENTS.md 350-line hard limit. - */ -describe('handlePublishVerdict — AC-H2 pipeline', () => { - /** @type {string} */ - let root; - - before(() => { - root = setupHarnessFeedback(); - }); - - after(() => { - rmSync(root, { recursive: true, force: true }); - }); - - describe('AC-H2 — GitPublisher isolated-worktree pipeline', () => { - it('happy path: handler calls gitPublisher with correct branchName/sourceBase + invokes stage callback in isolated worktree', async () => { - // 砚砚 R17 P1: seed LIVE evidence (gitignored, lives there); stage copies to isolated - seedLiveEvidence(root, 'snap.yaml', 'attr.yaml'); - const isolatedWorktree = makeEmptyIsolatedWorktree(); - const stageCalls = []; - const mockGitPublisher = { - async publishOnIsolatedWorktree(opts) { - const stageResult = await opts.stage(isolatedWorktree); - stageCalls.push({ branchName: opts.branchName, sourceBase: opts.sourceBase, stageResult }); - return { commitSha: 'sha1234567890', prUrl: 'https://github.com/zts212653/clowder-ai/pull/9999' }; - }, - }; - const mockGenerator = async (packet, sourceRefs, deps) => { - // PR-2 (砚砚 R1 Q1): generator gets RAW sourceRefs (basenames) + both roots. - // Each adapter handles its own resolve+copy (a2a) or provider.resolve (cw). - assert.equal(sourceRefs.snapshotName, 'snap.yaml'); - assert.equal(sourceRefs.attributionName, 'attr.yaml'); - assert.equal(deps.harnessFeedbackRoot, `${isolatedWorktree}/docs/harness-feedback`); - assert.equal(deps.liveHarnessFeedbackRoot, root, 'live root from handler deps.harnessFeedbackRoot'); - return { - verdictPath: `${deps.harnessFeedbackRoot}/verdicts/${packet.id}.md`, - bundleDir: `${deps.harnessFeedbackRoot}/bundles/${packet.id}`, - }; - }; - - const result = await handlePublishVerdict( - { harnessFeedbackRoot: root, gitPublisher: mockGitPublisher, generator: mockGenerator }, - { - packet: buildPacket({ id: 'vhp-h2-test', domainId: 'eval:a2a' }), - domain: 'eval:a2a', - catId: 'codex', - sourceRefs: { snapshotName: 'snap.yaml', attributionName: 'attr.yaml' }, - }, - ); - - assert.ok(!('error' in result), `expected success, got: ${JSON.stringify(result)}`); - assert.equal(result.commitSha, 'sha1234567890'); - assert.equal(result.prUrl, 'https://github.com/zts212653/clowder-ai/pull/9999'); - // 砚砚 R13 P2 cloud: response paths must be repo-relative (deterministic from - // packet.id), NOT the temp-worktree absolute paths the publisher just removed. - // If anyone reverts to returning artifact.verdictPath, this assertion breaks. - assert.equal(result.verdictPath, 'docs/harness-feedback/verdicts/vhp-h2-test.md'); - assert.equal(result.bundleDir, 'docs/harness-feedback/bundles/vhp-h2-test'); - - // Verify GitPublisher was called with correct opts - assert.equal(stageCalls.length, 1); - assert.equal(stageCalls[0].branchName, 'verdict/auto/eval-a2a/vhp-h2-test'); - assert.equal(stageCalls[0].sourceBase, 'origin/main'); - - // Verify stage callback returned correct artifacts + commit/PR shape - const stage = stageCalls[0].stageResult; - assert.equal(stage.paths.length, 2); // verdictPath + bundleDir - assert.match(stage.commitMessage, /verdict\(eval:a2a\): vhp-h2-test/); - assert.match(stage.commitMessage, /published via cat_cafe_publish_verdict MCP/); - assert.match(stage.prTitle, /verdict\(eval:a2a\)/); - }); - - it('returns 500 generator_failed when generator throws inside stage callback', async () => { - seedLiveEvidence(root, 'x.yaml', 'y.yaml'); - const mockGitPublisher = { - async publishOnIsolatedWorktree(opts) { - // Invoke stage which will throw via generator - await opts.stage(makeEmptyIsolatedWorktree()); - return { commitSha: 'unreachable', prUrl: 'unreachable' }; - }, - }; - const result = await handlePublishVerdict( - { - harnessFeedbackRoot: root, - gitPublisher: mockGitPublisher, - generator: async () => { - throw new Error('synthetic generator failure'); - }, - }, - { - packet: buildPacket({ domainId: 'eval:a2a' }), - domain: 'eval:a2a', - catId: 'codex', - sourceRefs: { snapshotName: 'x.yaml', attributionName: 'y.yaml' }, - }, - ); - assert.ok('error' in result); - assert.equal(result.status, 500); - assert.equal(result.error, 'generator_failed'); - assert.match(result.detail, /synthetic generator failure/); - }); - - it('returns 500 git_or_gh_failed when GitPublisher throws post-generator (push/PR failure)', async () => { - seedLiveEvidence(root, 'x.yaml', 'y.yaml'); - const mockGitPublisher = { - async publishOnIsolatedWorktree(opts) { - // Successful stage (generator returns artifact) then throws on commit/push/PR - await opts.stage(makeEmptyIsolatedWorktree()); - throw new Error('synthetic git push failure'); - }, - }; - const result = await handlePublishVerdict( - { - harnessFeedbackRoot: root, - gitPublisher: mockGitPublisher, - generator: async (p) => ({ - verdictPath: `/x/${p.id}.md`, - bundleDir: `/x/${p.id}`, - }), - }, - { - packet: buildPacket({ domainId: 'eval:a2a' }), - domain: 'eval:a2a', - catId: 'codex', - sourceRefs: { snapshotName: 'x.yaml', attributionName: 'y.yaml' }, - }, - ); - assert.ok('error' in result); - assert.equal(result.status, 500); - assert.equal(result.error, 'git_or_gh_failed'); - assert.match(result.detail, /synthetic git push failure/); - }); - - it('returns 500 git_or_gh_failed when GitPublisher throws BEFORE stage callback (e.g. worktree branch already exists — race protection)', async () => { - // 砚砚 R1 P2 #2: branch creation atomic — if branch exists, GitPublisher - // throws before invoking stage. Handler distinguishes by artifact==null - // → but in this case error category is git_or_gh_failed not generator_failed - // because stage was never invoked (artifact==null but generator never failed). - // Currently handler returns generator_failed when artifact==null. This is - // a known edge — duplicate-id race manifests as 'generator_failed' which - // is misleading. Documented; real GitPublisher impl will return distinct - // error category (e.g. 'duplicate_branch'). For now assert behavior is - // observable, not silent. - const mockGitPublisher = { - async publishOnIsolatedWorktree() { - throw new Error('fatal: A branch named verdict/auto/eval-a2a/dup already exists'); - }, - }; - const result = await handlePublishVerdict( - { - harnessFeedbackRoot: root, - gitPublisher: mockGitPublisher, - generator: async () => ({ verdictPath: '/x', bundleDir: '/x' }), - }, - { - packet: buildPacket({ id: 'dup', domainId: 'eval:a2a' }), - domain: 'eval:a2a', - catId: 'codex', - sourceRefs: { snapshotName: 'x.yaml', attributionName: 'y.yaml' }, - }, - ); - assert.ok('error' in result); - assert.equal(result.status, 500); - // generator_failed because stage was never invoked → artifact null - // (acceptable for v1; real impl returns better category in MCP wiring commit) - assert.equal(result.error, 'generator_failed'); - assert.match(result.detail, /branch.*already exists/); - }); - - // 砚砚 R3 P1 #2 cloud: live-tree dup-check is NOT authoritative. If origin/main - // has the verdict already but live tree is stale, isolated worktree (created - // from origin/main) WILL have the file. Stage callback re-checks and aborts - // with verdict_already_exists_on_main → handler surfaces 409 not 500. - it('returns 409 verdict_already_exists when verdict file pre-exists in isolated worktree (live tree was stale)', async () => { - const { mkdtempSync, mkdirSync, writeFileSync } = await import('node:fs'); - const { tmpdir } = await import('node:os'); - const { resolve } = await import('node:path'); - - const mockGitPublisher = { - async publishOnIsolatedWorktree(opts) { - // Simulate: isolated worktree was checked out from origin/main, which - // already has verdicts/stale-test.md (committed by parallel publish) - const fakeWorktree = mkdtempSync(`${tmpdir()}/phase-h-stale-`); - const verdictsDir = resolve(fakeWorktree, 'docs/harness-feedback/verdicts'); - mkdirSync(verdictsDir, { recursive: true }); - writeFileSync(resolve(verdictsDir, 'stale-test.md'), '# Already on main\n'); - // Now invoke stage — handler's authoritative re-check should throw - await opts.stage(fakeWorktree); - // If we reach here, the re-check didn't fire → test fails - return { commitSha: 'should-not-reach', prUrl: 'should-not-reach' }; - }, - }; - const result = await handlePublishVerdict( - { - harnessFeedbackRoot: root, - gitPublisher: mockGitPublisher, - generator: async () => { - throw new Error('generator should not be called when dup detected on main'); - }, - }, - { - packet: buildPacket({ id: 'stale-test', domainId: 'eval:a2a' }), - domain: 'eval:a2a', - catId: 'codex', - sourceRefs: { snapshotName: 'snap.yaml', attributionName: 'attr.yaml' }, - }, - ); - assert.ok('error' in result); - assert.equal(result.status, 409, 'must be 409 not 500'); - assert.equal(result.error, 'verdict_already_exists'); - assert.match(result.detail, /already exists on origin\/main|live tree was stale/); - }); - }); -}); diff --git a/packages/api/test/harness-eval/publish-verdict-task-outcome-writeback-guard.test.js b/packages/api/test/harness-eval/publish-verdict-task-outcome-writeback-guard.test.js index 20b6fe9273..1b57eb5738 100644 --- a/packages/api/test/harness-eval/publish-verdict-task-outcome-writeback-guard.test.js +++ b/packages/api/test/harness-eval/publish-verdict-task-outcome-writeback-guard.test.js @@ -89,19 +89,26 @@ function buildPacket(id) { }; } -function buildMockGitPublisher() { +function buildMockArtifactPublisher() { return { - async publishOnIsolatedWorktree(opts) { + async publishArtifact({ packet, generate }) { const iso = join(root, '..', `task-outcome-writeback-guard-iso-${Date.now()}`); - mkdirSync(join(iso, 'docs', 'harness-feedback', 'eval-domains'), { recursive: true }); + const outputRoot = join(iso, 'docs', 'harness-feedback'); + mkdirSync(join(outputRoot, 'eval-domains'), { recursive: true }); writeFileSync( - join(iso, 'docs', 'harness-feedback', 'eval-domains', 'eval-task-outcome.yaml'), + join(outputRoot, 'eval-domains', 'eval-task-outcome.yaml'), readFileSync(join(harnessFeedbackRoot, 'eval-domains', 'eval-task-outcome.yaml'), 'utf8'), ); try { - const stageResult = await opts.stage(iso); - await stageResult.afterPublish?.(); - return { commitSha: 'unreachable', prUrl: 'https://github.com/zts212653/clowder-ai/pull/9006' }; + const generated = await generate(outputRoot); + await generated.afterPublish?.(); + return { + artifactId: 'unreachable', + domainSlug: packet.domainId.replace(/:/g, '-'), + verdictPath: generated.verdictPath, + bundleDir: generated.bundleDir, + artifactUrl: 'artifact://eval-task-outcome/task-writeback-guard-9006', + }; } finally { rmSync(iso, { recursive: true, force: true }); } @@ -122,7 +129,7 @@ describe('task-outcome episode verdict writeback guards', () => { const result = await handlePublishVerdict( { harnessFeedbackRoot, - gitPublisher: buildMockGitPublisher(), + artifactPublisher: buildMockArtifactPublisher(), generator: createTaskOutcomeGeneratorAdapter(), taskOutcomeDbPath, }, @@ -151,20 +158,27 @@ describe('task-outcome episode verdict writeback guards', () => { const taskOutcomeDbPath = join(tmpdir(), `publish-verdict-taskoutcome-stale-pr-${Date.now()}.sqlite`); const seeded = seedTerminalEpisode(taskOutcomeDbPath); let exposedPr = false; - const gitPublisher = { - async publishOnIsolatedWorktree(opts) { + const artifactPublisher = { + async publishArtifact({ generate }) { const iso = join(root, '..', `task-outcome-writeback-stale-pr-iso-${Date.now()}`); - mkdirSync(join(iso, 'docs', 'harness-feedback', 'eval-domains'), { recursive: true }); + const outputRoot = join(iso, 'docs', 'harness-feedback'); + mkdirSync(join(outputRoot, 'eval-domains'), { recursive: true }); writeFileSync( - join(iso, 'docs', 'harness-feedback', 'eval-domains', 'eval-task-outcome.yaml'), + join(outputRoot, 'eval-domains', 'eval-task-outcome.yaml'), readFileSync(join(harnessFeedbackRoot, 'eval-domains', 'eval-task-outcome.yaml'), 'utf8'), ); try { - const stageResult = await opts.stage(iso); + const generated = await generate(outputRoot); new TaskOutcomeEpisodeStore(taskOutcomeDbPath).updateVerdict(seeded.episodeId, 'success'); - await stageResult.afterPublish?.(); + await generated.afterPublish?.(); exposedPr = true; - return { commitSha: 'unreachable', prUrl: 'https://github.com/zts212653/clowder-ai/pull/9007' }; + return { + artifactId: 'unreachable', + domainSlug: 'eval-task-outcome', + verdictPath: generated.verdictPath, + bundleDir: generated.bundleDir, + artifactUrl: 'artifact://eval-task-outcome/task-writeback-guard-9007', + }; } finally { rmSync(iso, { recursive: true, force: true }); } @@ -173,7 +187,7 @@ describe('task-outcome episode verdict writeback guards', () => { const result = await handlePublishVerdict( { harnessFeedbackRoot, - gitPublisher, + artifactPublisher, generator: createTaskOutcomeGeneratorAdapter(), taskOutcomeDbPath, }, diff --git a/packages/api/test/harness-eval/publish-verdict-task-outcome.test.js b/packages/api/test/harness-eval/publish-verdict-task-outcome.test.js index 390f1259cb..2a0c909864 100644 --- a/packages/api/test/harness-eval/publish-verdict-task-outcome.test.js +++ b/packages/api/test/harness-eval/publish-verdict-task-outcome.test.js @@ -109,18 +109,26 @@ function buildPacket(overrides = {}) { }; } -function buildMockGitPublisher(isoName, commitSha, prNumber) { +function buildMockArtifactPublisher(isoName, artifactId, artifactUrl) { return { - async publishOnIsolatedWorktree(opts) { + async publishArtifact({ packet, generate }) { const iso = join(root, '..', isoName); - mkdirSync(join(iso, 'docs', 'harness-feedback', 'eval-domains'), { recursive: true }); + const outputRoot = join(iso, 'docs', 'harness-feedback'); + mkdirSync(join(outputRoot, 'eval-domains'), { recursive: true }); writeFileSync( - join(iso, 'docs', 'harness-feedback', 'eval-domains', 'eval-task-outcome.yaml'), + join(outputRoot, 'eval-domains', 'eval-task-outcome.yaml'), readFileSync(join(harnessFeedbackRoot, 'eval-domains', 'eval-task-outcome.yaml'), 'utf8'), ); - await (await opts.stage(iso)).afterPublish?.(); + const generated = await generate(outputRoot); + await generated.afterPublish?.(); rmSync(iso, { recursive: true, force: true }); - return { commitSha, prUrl: `https://github.com/zts212653/clowder-ai/pull/${prNumber}` }; + return { + artifactId, + domainSlug: packet.domainId.replace(/:/g, '-'), + verdictPath: generated.verdictPath, + bundleDir: generated.bundleDir, + artifactUrl, + }; }, }; } @@ -137,10 +145,14 @@ after(() => { describe('handlePublishVerdict end-to-end with task-outcome generator', () => { it('happy path: handler dispatches to task-outcome adapter and returns repo-relative verdict paths', async () => { const generator = createTaskOutcomeGeneratorAdapter(); - const mockGitPublisher = buildMockGitPublisher('task-outcome-e2e-iso', 'task-sha-1234', 9001); + const artifactPublisher = buildMockArtifactPublisher( + 'task-outcome-e2e-iso', + 'task-sha-1234', + 'artifact://eval-task-outcome/task-artifact-1234', + ); const result = await handlePublishVerdict( - { harnessFeedbackRoot: harnessFeedbackRoot, gitPublisher: mockGitPublisher, generator }, + { harnessFeedbackRoot: harnessFeedbackRoot, artifactPublisher, generator }, { packet: buildPacket(), domain: 'eval:task-outcome', @@ -155,22 +167,27 @@ describe('handlePublishVerdict end-to-end with task-outcome generator', () => { ); assert.ok(!('error' in result), `expected success, got: ${JSON.stringify(result)}`); - assert.equal(result.commitSha, 'task-sha-1234'); - assert.equal(result.prUrl, 'https://github.com/zts212653/clowder-ai/pull/9001'); - assert.equal(result.verdictPath, 'docs/harness-feedback/verdicts/vhp-task-outcome-e2e-test.md'); - assert.equal(result.bundleDir, 'docs/harness-feedback/bundles/vhp-task-outcome-e2e-test'); + assert.equal(result.artifactId, 'task-sha-1234'); + assert.equal(result.artifactUrl, 'artifact://eval-task-outcome/task-artifact-1234'); + // F257 / F192 sunset: ArtifactPublisher returns absolute store paths; assert suffix. + assert.match(result.verdictPath, /verdicts\/vhp-task-outcome-e2e-test\.md$/); + assert.match(result.bundleDir, /bundles\/vhp-task-outcome-e2e-test$/); }); it('uses runtime-configured taskOutcomeDbPath when sourceRefs omit databasePath', async () => { const customTaskOutcomeDbPath = join(tmpdir(), `publish-verdict-taskoutcome-custom-${Date.now()}.sqlite`); await seedWindow(customTaskOutcomeDbPath); const generator = createTaskOutcomeGeneratorAdapter(); - const mockGitPublisher = buildMockGitPublisher('task-outcome-configured-db-iso', 'task-sha-5678', 9002); + const artifactPublisher = buildMockArtifactPublisher( + 'task-outcome-configured-db-iso', + 'task-sha-5678', + 'artifact://eval-task-outcome/task-artifact-5678', + ); const result = await handlePublishVerdict( { harnessFeedbackRoot: harnessFeedbackRoot, - gitPublisher: mockGitPublisher, + artifactPublisher, generator, taskOutcomeDbPath: customTaskOutcomeDbPath, }, @@ -188,19 +205,23 @@ describe('handlePublishVerdict end-to-end with task-outcome generator', () => { ); assert.ok(!('error' in result), `expected success, got: ${JSON.stringify(result)}`); - assert.equal(result.commitSha, 'task-sha-5678'); + assert.equal(result.artifactId, 'task-sha-5678'); }); it('writes explicit 7-class episode verdicts back to the task-outcome DB', async () => { const customTaskOutcomeDbPath = join(tmpdir(), `publish-verdict-taskoutcome-writeback-${Date.now()}.sqlite`); const seeded = await seedWindow(customTaskOutcomeDbPath); const generator = createTaskOutcomeGeneratorAdapter(); - const mockGitPublisher = buildMockGitPublisher('task-outcome-writeback-iso', 'task-sha-writeback', 9003); + const artifactPublisher = buildMockArtifactPublisher( + 'task-outcome-writeback-iso', + 'task-sha-writeback', + 'artifact://eval-task-outcome/task-artifact-writeback', + ); const result = await handlePublishVerdict( { harnessFeedbackRoot: harnessFeedbackRoot, - gitPublisher: mockGitPublisher, + artifactPublisher, generator, taskOutcomeDbPath: customTaskOutcomeDbPath, }, @@ -219,7 +240,7 @@ describe('handlePublishVerdict end-to-end with task-outcome generator', () => { ); assert.ok(!('error' in result), `expected success, got: ${JSON.stringify(result)}`); - assert.equal(result.commitSha, 'task-sha-writeback'); + assert.equal(result.artifactId, 'task-sha-writeback'); const store = new TaskOutcomeEpisodeStore(customTaskOutcomeDbPath); assert.equal(store.getEpisode(seeded.episodeId)?.verdict, 'corrected_success'); @@ -234,23 +255,24 @@ describe('handlePublishVerdict end-to-end with task-outcome generator', () => { const customTaskOutcomeDbPath = join(tmpdir(), `publish-verdict-taskoutcome-publish-fail-${Date.now()}.sqlite`); const seeded = await seedWindow(customTaskOutcomeDbPath); const generator = createTaskOutcomeGeneratorAdapter(); - const failingGitPublisher = { - async publishOnIsolatedWorktree(opts) { + const failingArtifactPublisher = { + async publishArtifact({ generate }) { const iso = join(root, '..', 'task-outcome-writeback-publish-fail-iso'); - mkdirSync(join(iso, 'docs', 'harness-feedback', 'eval-domains'), { recursive: true }); + const outputRoot = join(iso, 'docs', 'harness-feedback'); + mkdirSync(join(outputRoot, 'eval-domains'), { recursive: true }); writeFileSync( - join(iso, 'docs', 'harness-feedback', 'eval-domains', 'eval-task-outcome.yaml'), + join(outputRoot, 'eval-domains', 'eval-task-outcome.yaml'), readFileSync(join(harnessFeedbackRoot, 'eval-domains', 'eval-task-outcome.yaml'), 'utf8'), ); - await opts.stage(iso); + await generate(outputRoot); rmSync(iso, { recursive: true, force: true }); - throw new Error('simulated gh pr create failure'); + throw new Error('simulated artifact publish failure'); }, }; const result = await handlePublishVerdict( { harnessFeedbackRoot, - gitPublisher: failingGitPublisher, + artifactPublisher: failingArtifactPublisher, generator, taskOutcomeDbPath: customTaskOutcomeDbPath, }, @@ -270,7 +292,7 @@ describe('handlePublishVerdict end-to-end with task-outcome generator', () => { const store = new TaskOutcomeEpisodeStore(customTaskOutcomeDbPath); assert.equal(result.status, 500); - assert.equal(result.error, 'git_or_gh_failed'); + assert.equal(result.error, 'publisher_failed'); assert.equal(store.getEpisode(seeded.episodeId)?.verdict, null); }); @@ -285,12 +307,16 @@ describe('handlePublishVerdict end-to-end with task-outcome generator', () => { participants: ['gpt52'], }); const generator = createTaskOutcomeGeneratorAdapter(); - const mockGitPublisher = buildMockGitPublisher('task-outcome-writeback-invalid-iso', 'unreachable', 9004); + const artifactPublisher = buildMockArtifactPublisher( + 'task-outcome-writeback-invalid-iso', + 'unreachable', + 'artifact://eval-task-outcome/task-artifact-invalid-terminal', + ); const result = await handlePublishVerdict( { harnessFeedbackRoot: harnessFeedbackRoot, - gitPublisher: mockGitPublisher, + artifactPublisher, generator, taskOutcomeDbPath: customTaskOutcomeDbPath, }, @@ -319,12 +345,16 @@ describe('handlePublishVerdict end-to-end with task-outcome generator', () => { const seeded = await seedWindow(customTaskOutcomeDbPath); const invalidVerdictId = `vhp-task-outcome-e2e-writeback-outside-${Math.random().toString(36).slice(2, 8)}`; const generator = createTaskOutcomeGeneratorAdapter(); - const mockGitPublisher = buildMockGitPublisher('task-outcome-writeback-outside-iso', 'unreachable', 9005); + const artifactPublisher = buildMockArtifactPublisher( + 'task-outcome-writeback-outside-iso', + 'unreachable', + 'artifact://eval-task-outcome/task-artifact-outside-window', + ); const result = await handlePublishVerdict( { harnessFeedbackRoot: harnessFeedbackRoot, - gitPublisher: mockGitPublisher, + artifactPublisher, generator, taskOutcomeDbPath: customTaskOutcomeDbPath, }, diff --git a/packages/api/test/harness-eval/publish-verdict.test.js b/packages/api/test/harness-eval/publish-verdict.test.js index edd28be888..203b3884fc 100644 --- a/packages/api/test/harness-eval/publish-verdict.test.js +++ b/packages/api/test/harness-eval/publish-verdict.test.js @@ -1,16 +1,18 @@ import assert from 'node:assert/strict'; -import { rmSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; import { after, before, describe, it } from 'node:test'; +import { createLocalArtifactPublisher } from '../../dist/infrastructure/harness-eval/publish-verdict/local-artifact-publisher.js'; import { handlePublishVerdict } from '../../dist/infrastructure/harness-eval/publish-verdict/publish-verdict.js'; import { setupHarnessFeedback } from './eval-manual-trigger-fixtures.js'; -import { buildPacket } from './publish-verdict-fixtures.js'; +import { buildPacket, createMockArtifactPublisher } from './publish-verdict-fixtures.js'; /** * F192 Phase H — Verdict Publishing Pipeline (砚砚 R0 Path B narrowed). * AC-H1: packet schema validation. - * AC-H2: branch + commit + push + auto-PR pipeline (exec + generator injected). + * AC-H2: atomic durable artifact publication (publisher + generator injected). * AC-H7 partial: domain↔packet cross-check + eval:a2a-only v1. */ describe('handlePublishVerdict', () => { @@ -421,23 +423,99 @@ fixtures: [] assert.match(result.detail, /phenomenon.*2048/); }); - it('returns 409 verdict_already_exists when verdict file already exists for this id', async () => { - const { mkdirSync, writeFileSync } = await import('node:fs'); - const { resolve } = await import('node:path'); + it('returns 409 verdict_already_exists when artifact publisher detects duplicate id', async () => { const dupId = 'dup-verdict-test'; - mkdirSync(resolve(root, 'verdicts'), { recursive: true }); - writeFileSync(resolve(root, 'verdicts', `${dupId}.md`), '# Existing verdict\n'); + const mockPublisher = createMockArtifactPublisher({ duplicateIds: new Set([dupId]) }); + const mockGenerator = async (packet, sources, deps) => ({ + verdictPath: `${deps.harnessFeedbackRoot}/verdicts/${packet.id}.md`, + bundleDir: `${deps.harnessFeedbackRoot}/bundles/${packet.id}`, + }); const result = await handlePublishVerdict( - { harnessFeedbackRoot: root }, - { packet: buildPacket({ id: dupId, domainId: 'eval:a2a' }), domain: 'eval:a2a', catId: 'codex' }, + { harnessFeedbackRoot: root, artifactPublisher: mockPublisher, generator: mockGenerator }, + { + packet: buildPacket({ id: dupId, domainId: 'eval:a2a' }), + domain: 'eval:a2a', + catId: 'codex', + sourceRefs: { snapshotName: 'snap.yaml', attributionName: 'attr.yaml' }, + }, ); assert.ok('error' in result); assert.equal(result.status, 409); assert.equal(result.error, 'verdict_already_exists'); - assert.match(result.detail, /data integrity|forbidden/i); + }); + + // F257 R5 P2: publisher rollback must preserve typed domain errors so the + // handler mapping layer returns the correct 4xx instead of 500 publisher_failed. + it('returns 400 invalid_episode_verdict_writeback when afterPublish throws typed domain error', async () => { + const mockPublisher = createMockArtifactPublisher(); + const mockGenerator = async (packet, sources, deps) => ({ + verdictPath: `${deps.harnessFeedbackRoot}/verdicts/${packet.id}.md`, + bundleDir: `${deps.harnessFeedbackRoot}/bundles/${packet.id}`, + afterPublish() { + throw new Error('invalid_episode_verdict_writeback: stale claim'); + }, + }); + + const result = await handlePublishVerdict( + { harnessFeedbackRoot: root, artifactPublisher: mockPublisher, generator: mockGenerator }, + { + packet: buildPacket({ domainId: 'eval:a2a' }), + domain: 'eval:a2a', + catId: 'codex', + sourceRefs: { snapshotName: 'snap.yaml', attributionName: 'attr.yaml' }, + }, + ); + assert.ok('error' in result); + assert.equal(result.status, 400); + assert.equal(result.error, 'invalid_episode_verdict_writeback'); + }); + + // F257 R6 P2: the rollback path must work with the REAL LocalArtifactPublisher, + // not just a mock. The artifact is atomically committed, afterPublish fails with + // a typed domain error, the artifact is rolled back, and the handler still maps + // it to 400 invalid_episode_verdict_writeback instead of 500 publisher_failed. + it('returns 400 invalid_episode_verdict_writeback with real LocalArtifactPublisher', async () => { + const artifactRoot = mkdtempSync(join(tmpdir(), 'r7-real-publisher-')); + try { + const artifactPublisher = createLocalArtifactPublisher({ artifactRoot }); + const testId = 'real-publisher-typed-error'; + const realGenerator = async (packet, sources, deps) => { + const verdictPath = resolve(deps.harnessFeedbackRoot, 'verdicts', `${packet.id}.md`); + const bundleDir = resolve(deps.harnessFeedbackRoot, 'bundles', packet.id); + mkdirSync(resolve(deps.harnessFeedbackRoot, 'verdicts'), { recursive: true }); + mkdirSync(bundleDir, { recursive: true }); + writeFileSync(verdictPath, '# verdict\n'); + return { + verdictPath, + bundleDir, + afterPublish() { + throw new Error('invalid_episode_verdict_writeback: stale claim'); + }, + }; + }; + + const result = await handlePublishVerdict( + { harnessFeedbackRoot: root, artifactPublisher, generator: realGenerator }, + { + packet: buildPacket({ id: testId, domainId: 'eval:a2a' }), + domain: 'eval:a2a', + catId: 'codex', + sourceRefs: { snapshotName: 'snap.yaml', attributionName: 'attr.yaml' }, + }, + ); + assert.ok('error' in result); + assert.equal(result.status, 400); + assert.equal(result.error, 'invalid_episode_verdict_writeback'); + + // Rollback guarantee: the committed artifact must not be left behind. + const finalDir = resolve(artifactRoot, 'eval-a2a', testId); + assert.equal(existsSync(finalDir), false, 'artifact should be rolled back after afterPublish failure'); + } finally { + rmSync(artifactRoot, { recursive: true, force: true }); + } }); }); - // AC-H2 + 砚砚 R1 P1 #1: pipeline mechanics via GitPublisher abstraction + // AC-H2 + 砚砚 R1 P1 #1: pipeline mechanics via ArtifactPublisher abstraction }); diff --git a/packages/api/test/harness-eval/segment-judgment-engine.test.js b/packages/api/test/harness-eval/segment-judgment-engine.test.js new file mode 100644 index 0000000000..adcdcef6e2 --- /dev/null +++ b/packages/api/test/harness-eval/segment-judgment-engine.test.js @@ -0,0 +1,700 @@ +/** + * F257 Segment Judgment Engine tests + * + * Verifies: + * - Per-segment aggregation from injection traces + * - Guard event correlation via ±120s timestamp window + * - Deterministic verdict rules (alive / unmeasurable) + * - rawGuardEvents preference over snapshot sampleAnchors + * - Empty input edge cases + * - JudgmentId formatting + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { produceSegmentJudgments } from '../../dist/infrastructure/harness-eval/segment-judgment-engine.js'; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +/** Fake InjectionTraceStore — returns preloaded summaries by threadId. */ +class FakeTraceStore { + constructor(summariesByThread = {}) { + this.data = summariesByThread; // { threadId → InjectionTraceSummary[] } + } + + async queryWindow(threadId, startMs, endMs) { + const all = this.data[threadId] ?? []; + return all.filter((s) => s.timestamp >= startMs && s.timestamp <= endMs); + } +} + +function makeTrace({ threadId, catId, turnId, timestamp, segments }) { + return { + threadId, + catId, + turnId: turnId ?? `turn-${timestamp}`, + timestamp, + segments, + delivery: [], + totals: { charCount: 0, tokenEstimate: 0 }, + }; +} + +function makeSeg({ segmentId, status = 'observed', pipelineStatus = 'fired', version = null }) { + return { + segmentId, + stage: 'session', + status, + pipelineStatus, + contentHash: 'h', + charCount: 10, + tokenEstimate: 3, + version, + }; +} + +function makeSnapshot({ evalRunId = 'hlr-test-001', startMs, endMs, sampleAnchors = [], byGuard = {}, byKind = {} }) { + return { + evalRunId, + producedAt: '2026-07-14T00:00:00.000Z', + window: { startMs, endMs, durationHours: Math.round((endMs - startMs) / 3_600_000) }, + totalEvents: sampleAnchors.length, + byKind, + byGuard, + sampleAnchors, + howCounted: 'zset-window-scan', + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('F257 Segment Judgment Engine', () => { + const T0 = 1_720_900_000_000; // base timestamp + const WINDOW_START = T0 - 86_400_000; // 1 day before + const WINDOW_END = T0 + 86_400_000; // 1 day after + + describe('empty inputs', () => { + test('returns [] when no threadIds provided', async () => { + const store = new FakeTraceStore(); + const result = await produceSegmentJudgments( + { traceStore: store }, + { + snapshot: makeSnapshot({ startMs: WINDOW_START, endMs: WINDOW_END }), + evalCat: 'ragdoll', + threadIds: [], + }, + ); + assert.deepStrictEqual(result, []); + }); + + test('returns [] when no traces in window', async () => { + const store = new FakeTraceStore({ 'thread-1': [] }); + const result = await produceSegmentJudgments( + { traceStore: store }, + { + snapshot: makeSnapshot({ startMs: WINDOW_START, endMs: WINDOW_END }), + evalCat: 'ragdoll', + threadIds: ['thread-1'], + }, + ); + assert.deepStrictEqual(result, []); + }); + }); + + describe('per-segment aggregation', () => { + test('counts fired segments across multiple traces', async () => { + const store = new FakeTraceStore({ + 'thread-1': [ + makeTrace({ + threadId: 'thread-1', + catId: 'cat-a', + timestamp: T0, + segments: [makeSeg({ segmentId: 'S-identity-contract' }), makeSeg({ segmentId: 'S-safety-rules' })], + }), + makeTrace({ + threadId: 'thread-1', + catId: 'cat-a', + timestamp: T0 + 60_000, + segments: [makeSeg({ segmentId: 'S-identity-contract' })], + }), + ], + }); + + const result = await produceSegmentJudgments( + { traceStore: store }, + { + snapshot: makeSnapshot({ startMs: WINDOW_START, endMs: WINDOW_END }), + evalCat: 'ragdoll', + threadIds: ['thread-1'], + }, + ); + + assert.equal(result.length, 2); + const identity = result.find((j) => j.segmentId === 'S-identity-contract'); + const safety = result.find((j) => j.segmentId === 'S-safety-rules'); + assert.equal(identity.evidence.injectionCount.value, 2); + assert.equal(safety.evidence.injectionCount.value, 1); + }); + + test('skips per-turn-aggregate and session-init-pack-only segments', async () => { + const store = new FakeTraceStore({ + 'thread-1': [ + makeTrace({ + threadId: 'thread-1', + catId: 'cat-a', + timestamp: T0, + segments: [ + makeSeg({ segmentId: 'per-turn-aggregate' }), + makeSeg({ segmentId: 'session-init-pack-only' }), + makeSeg({ segmentId: 'S-real-hook' }), + ], + }), + ], + }); + + const result = await produceSegmentJudgments( + { traceStore: store }, + { + snapshot: makeSnapshot({ startMs: WINDOW_START, endMs: WINDOW_END }), + evalCat: 'ragdoll', + threadIds: ['thread-1'], + }, + ); + + assert.equal(result.length, 1); + assert.equal(result[0].segmentId, 'S-real-hook'); + }); + + test('does not count non-fired segments', async () => { + const store = new FakeTraceStore({ + 'thread-1': [ + makeTrace({ + threadId: 'thread-1', + catId: 'cat-a', + timestamp: T0, + segments: [ + makeSeg({ segmentId: 'S-hook-a', status: 'observed', pipelineStatus: 'fired' }), + makeSeg({ segmentId: 'S-hook-b', status: 'observed', pipelineStatus: 'skipped' }), + ], + }), + ], + }); + + const result = await produceSegmentJudgments( + { traceStore: store }, + { + snapshot: makeSnapshot({ startMs: WINDOW_START, endMs: WINDOW_END }), + evalCat: 'ragdoll', + threadIds: ['thread-1'], + }, + ); + + const hookA = result.find((j) => j.segmentId === 'S-hook-a'); + const hookB = result.find((j) => j.segmentId === 'S-hook-b'); + assert.equal(hookA.evidence.injectionCount.value, 1); + assert.equal(hookA.verdict, 'alive'); + assert.equal(hookB.evidence.injectionCount.value, 0); + assert.equal(hookB.verdict, 'unmeasurable'); + }); + + test('tracks segment version', async () => { + const store = new FakeTraceStore({ + 'thread-1': [ + makeTrace({ + threadId: 'thread-1', + catId: 'cat-a', + timestamp: T0, + segments: [makeSeg({ segmentId: 'S-hook', version: 3 })], + }), + ], + }); + + const result = await produceSegmentJudgments( + { traceStore: store }, + { + snapshot: makeSnapshot({ startMs: WINDOW_START, endMs: WINDOW_END }), + evalCat: 'ragdoll', + threadIds: ['thread-1'], + }, + ); + + assert.equal(result[0].segmentVersion, 3); + }); + }); + + describe('guard event correlation', () => { + test('correlates events within ±120s of fired trace timestamp (three-key match)', async () => { + const traceTs = T0; + const store = new FakeTraceStore({ + 'thread-1': [ + makeTrace({ + threadId: 'thread-1', + catId: 'cat-a', + timestamp: traceTs, + segments: [makeSeg({ segmentId: 'S-identity' })], + }), + ], + }); + + const result = await produceSegmentJudgments( + { traceStore: store }, + { + snapshot: makeSnapshot({ + startMs: WINDOW_START, + endMs: WINDOW_END, + }), + evalCat: 'ragdoll', + threadIds: ['thread-1'], + // v1 three-key correlation: rawGuardEvents carry threadId + catId. + // ev-1: same thread + same cat + within ±120s → matches. + // ev-2: same thread + same cat but outside ±120s → no match. + rawGuardEvents: [ + { + eventId: 'ev-1', + guardId: 'identity-guard', + threadId: 'thread-1', + catId: 'cat-a', + timestamp: traceTs + 60_000, + }, + { + eventId: 'ev-2', + guardId: 'safety-guard', + threadId: 'thread-1', + catId: 'cat-a', + timestamp: traceTs + 200_000, + }, + ], + }, + ); + + assert.equal(result[0].evidence.violationCount.value, 1); // ev-1 within ±120s, ev-2 outside + assert.deepStrictEqual(result[0].evidence.eventRefs, ['ev-1']); + assert.equal(result[0].verdict, 'alive'); + }); + + test('no correlation when guard event outside ±120s window', async () => { + const store = new FakeTraceStore({ + 'thread-1': [ + makeTrace({ + threadId: 'thread-1', + catId: 'cat-a', + timestamp: T0, + segments: [makeSeg({ segmentId: 'S-hook' })], + }), + ], + }); + + const result = await produceSegmentJudgments( + { traceStore: store }, + { + snapshot: makeSnapshot({ + startMs: WINDOW_START, + endMs: WINDOW_END, + }), + evalCat: 'ragdoll', + threadIds: ['thread-1'], + // Same thread + same cat but 300s away → outside ±120s window. + rawGuardEvents: [ + { eventId: 'ev-far', guardId: 'g', threadId: 'thread-1', catId: 'cat-a', timestamp: T0 + 300_000 }, + ], + }, + ); + + assert.equal(result[0].evidence.violationCount.value, 0); + assert.equal(result[0].verdict, 'alive'); // has injections, no correlated violations → still alive + }); + }); + + describe('rawGuardEvents preference', () => { + test('uses rawGuardEvents over snapshot sampleAnchors', async () => { + const store = new FakeTraceStore({ + 'thread-1': [ + makeTrace({ + threadId: 'thread-1', + catId: 'cat-a', + timestamp: T0, + segments: [makeSeg({ segmentId: 'S-hook' })], + }), + ], + }); + + // sampleAnchors has an event at +60s (within window) + // rawGuardEvents has 2 events at +30s and +90s (both within window) + const result = await produceSegmentJudgments( + { traceStore: store }, + { + snapshot: makeSnapshot({ + startMs: WINDOW_START, + endMs: WINDOW_END, + sampleAnchors: [{ eventId: 'anchor-1', kind: 'x', guardId: 'g', timestamp: T0 + 60_000 }], + }), + evalCat: 'ragdoll', + threadIds: ['thread-1'], + rawGuardEvents: [ + { eventId: 'raw-1', guardId: 'g', threadId: 'thread-1', catId: 'cat-a', timestamp: T0 + 30_000 }, + { eventId: 'raw-2', guardId: 'g', threadId: 'thread-1', catId: 'cat-a', timestamp: T0 + 90_000 }, + ], + }, + ); + + // Should see 2 correlated events from raw, not 1 from sampleAnchors + assert.equal(result[0].evidence.violationCount.value, 2); + assert.ok(result[0].evidence.eventRefs.includes('raw-1')); + assert.ok(result[0].evidence.eventRefs.includes('raw-2')); + assert.ok(!result[0].evidence.eventRefs.includes('anchor-1')); + }); + }); + + describe('verdict rules', () => { + test('alive when injections > 0 (even without violations)', async () => { + const store = new FakeTraceStore({ + 'thread-1': [ + makeTrace({ + threadId: 'thread-1', + catId: 'cat-a', + timestamp: T0, + segments: [makeSeg({ segmentId: 'S-clean-hook' })], + }), + ], + }); + + const result = await produceSegmentJudgments( + { traceStore: store }, + { + snapshot: makeSnapshot({ startMs: WINDOW_START, endMs: WINDOW_END }), + evalCat: 'ragdoll', + threadIds: ['thread-1'], + }, + ); + + assert.equal(result[0].verdict, 'alive'); + assert.equal(result[0].evidence.denominatorKind, 'fired-count'); + }); + + test('unmeasurable when injections == 0 (skipped segment)', async () => { + const store = new FakeTraceStore({ + 'thread-1': [ + makeTrace({ + threadId: 'thread-1', + catId: 'cat-a', + timestamp: T0, + segments: [makeSeg({ segmentId: 'S-skipped', status: 'observed', pipelineStatus: 'skipped' })], + }), + ], + }); + + const result = await produceSegmentJudgments( + { traceStore: store }, + { + snapshot: makeSnapshot({ startMs: WINDOW_START, endMs: WINDOW_END }), + evalCat: 'ragdoll', + threadIds: ['thread-1'], + }, + ); + + assert.equal(result[0].verdict, 'unmeasurable'); + assert.equal(result[0].evidence.denominatorKind, 'none'); + }); + }); + + describe('judgment metadata', () => { + test('judgmentId follows sj-YYYYMMDD-NNN format', async () => { + const store = new FakeTraceStore({ + 'thread-1': [ + makeTrace({ + threadId: 'thread-1', + catId: 'cat-a', + timestamp: T0, + segments: [makeSeg({ segmentId: 'S-a' }), makeSeg({ segmentId: 'S-b' })], + }), + ], + }); + + const result = await produceSegmentJudgments( + { traceStore: store }, + { + snapshot: makeSnapshot({ startMs: WINDOW_START, endMs: WINDOW_END }), + evalCat: 'ragdoll', + threadIds: ['thread-1'], + }, + ); + + assert.match(result[0].judgmentId, /^sj-20260714-001$/); + assert.match(result[1].judgmentId, /^sj-20260714-002$/); + }); + + test('window matches snapshot window', async () => { + const store = new FakeTraceStore({ + 'thread-1': [ + makeTrace({ + threadId: 'thread-1', + catId: 'cat-a', + timestamp: T0, + segments: [makeSeg({ segmentId: 'S-hook' })], + }), + ], + }); + + const result = await produceSegmentJudgments( + { traceStore: store }, + { + snapshot: makeSnapshot({ startMs: WINDOW_START, endMs: WINDOW_END }), + evalCat: 'ragdoll', + threadIds: ['thread-1'], + }, + ); + + assert.deepStrictEqual(result[0].window, { startMs: WINDOW_START, endMs: WINDOW_END }); + }); + + test('producedBy carries evalCat and evalRunId', async () => { + const store = new FakeTraceStore({ + 'thread-1': [ + makeTrace({ + threadId: 'thread-1', + catId: 'cat-a', + timestamp: T0, + segments: [makeSeg({ segmentId: 'S-hook' })], + }), + ], + }); + + const result = await produceSegmentJudgments( + { traceStore: store }, + { + snapshot: makeSnapshot({ evalRunId: 'hlr-test-xyz', startMs: WINDOW_START, endMs: WINDOW_END }), + evalCat: 'fable', + threadIds: ['thread-1'], + }, + ); + + assert.equal(result[0].producedBy.evalCat, 'fable'); + assert.equal(result[0].producedBy.runId, 'hlr-test-xyz'); + assert.equal(result[0].producedBy.domainId, 'eval:harness-ledger'); + }); + + test('correlationConfidence is always window in v1', async () => { + const store = new FakeTraceStore({ + 'thread-1': [ + makeTrace({ + threadId: 'thread-1', + catId: 'cat-a', + timestamp: T0, + segments: [makeSeg({ segmentId: 'S-hook' })], + }), + ], + }); + + const result = await produceSegmentJudgments( + { traceStore: store }, + { + snapshot: makeSnapshot({ startMs: WINDOW_START, endMs: WINDOW_END }), + evalCat: 'ragdoll', + threadIds: ['thread-1'], + }, + ); + + assert.equal(result[0].evidence.correlationConfidence, 'window'); + }); + }); + + describe('multi-thread aggregation', () => { + test('aggregates same segment across different threads', async () => { + const store = new FakeTraceStore({ + 'thread-1': [ + makeTrace({ + threadId: 'thread-1', + catId: 'cat-a', + timestamp: T0, + segments: [makeSeg({ segmentId: 'S-shared-hook' })], + }), + ], + 'thread-2': [ + makeTrace({ + threadId: 'thread-2', + catId: 'cat-b', + timestamp: T0 + 1000, + segments: [makeSeg({ segmentId: 'S-shared-hook' })], + }), + ], + }); + + const result = await produceSegmentJudgments( + { traceStore: store }, + { + snapshot: makeSnapshot({ startMs: WINDOW_START, endMs: WINDOW_END }), + evalCat: 'ragdoll', + threadIds: ['thread-1', 'thread-2'], + }, + ); + + assert.equal(result.length, 1); + assert.equal(result[0].segmentId, 'S-shared-hook'); + assert.equal(result[0].evidence.injectionCount.value, 2); + }); + }); + + describe('v1 three-key correlation (terra review P1-2)', () => { + test('same-tuple matches: same threadId + same catId + within ±120s', async () => { + const store = new FakeTraceStore({ + 'thread-1': [ + makeTrace({ + threadId: 'thread-1', + catId: 'cat-a', + timestamp: T0, + segments: [makeSeg({ segmentId: 'S-hook' })], + }), + ], + }); + + const result = await produceSegmentJudgments( + { traceStore: store }, + { + snapshot: makeSnapshot({ startMs: WINDOW_START, endMs: WINDOW_END }), + evalCat: 'ragdoll', + threadIds: ['thread-1'], + rawGuardEvents: [ + { eventId: 'ev-match', guardId: 'g', threadId: 'thread-1', catId: 'cat-a', timestamp: T0 + 50_000 }, + ], + }, + ); + + assert.equal(result[0].evidence.violationCount.value, 1); + assert.deepStrictEqual(result[0].evidence.eventRefs, ['ev-match']); + }); + + test('different threadId within ±120s does NOT match', async () => { + const store = new FakeTraceStore({ + 'thread-1': [ + makeTrace({ + threadId: 'thread-1', + catId: 'cat-a', + timestamp: T0, + segments: [makeSeg({ segmentId: 'S-hook' })], + }), + ], + }); + + const result = await produceSegmentJudgments( + { traceStore: store }, + { + snapshot: makeSnapshot({ startMs: WINDOW_START, endMs: WINDOW_END }), + evalCat: 'ragdoll', + threadIds: ['thread-1'], + rawGuardEvents: [ + // Same catId + within ±120s, but different threadId → no match + { + eventId: 'ev-cross-thread', + guardId: 'g', + threadId: 'thread-OTHER', + catId: 'cat-a', + timestamp: T0 + 10_000, + }, + ], + }, + ); + + assert.equal(result[0].evidence.violationCount.value, 0); + assert.deepStrictEqual(result[0].evidence.eventRefs, []); + }); + + test('different catId within ±120s does NOT match', async () => { + const store = new FakeTraceStore({ + 'thread-1': [ + makeTrace({ + threadId: 'thread-1', + catId: 'cat-a', + timestamp: T0, + segments: [makeSeg({ segmentId: 'S-hook' })], + }), + ], + }); + + const result = await produceSegmentJudgments( + { traceStore: store }, + { + snapshot: makeSnapshot({ startMs: WINDOW_START, endMs: WINDOW_END }), + evalCat: 'ragdoll', + threadIds: ['thread-1'], + rawGuardEvents: [ + // Same threadId + within ±120s, but different catId → no match + { + eventId: 'ev-cross-cat', + guardId: 'g', + threadId: 'thread-1', + catId: 'cat-DIFFERENT', + timestamp: T0 + 10_000, + }, + ], + }, + ); + + assert.equal(result[0].evidence.violationCount.value, 0); + assert.deepStrictEqual(result[0].evidence.eventRefs, []); + }); + + test('sampleAnchors without rawGuardEvents yields 0 violations (no false attribution)', async () => { + const store = new FakeTraceStore({ + 'thread-1': [ + makeTrace({ + threadId: 'thread-1', + catId: 'cat-a', + timestamp: T0, + segments: [makeSeg({ segmentId: 'S-hook' })], + }), + ], + }); + + // sampleAnchors present in snapshot but no rawGuardEvents passed → + // engine must return 0 violations (sampleAnchors lack threadId/catId). + const result = await produceSegmentJudgments( + { traceStore: store }, + { + snapshot: makeSnapshot({ + startMs: WINDOW_START, + endMs: WINDOW_END, + sampleAnchors: [{ eventId: 'anchor-1', kind: 'x', guardId: 'g', timestamp: T0 + 10_000 }], + }), + evalCat: 'ragdoll', + threadIds: ['thread-1'], + // rawGuardEvents intentionally omitted + }, + ); + + assert.equal(result[0].evidence.violationCount.value, 0); + assert.deepStrictEqual(result[0].evidence.eventRefs, []); + }); + }); + + describe('evalCat provenance (terra review P2-2)', () => { + test('producedBy.evalCat reflects the effective eval cat, not a default', async () => { + const store = new FakeTraceStore({ + 'thread-1': [ + makeTrace({ + threadId: 'thread-1', + catId: 'cat-a', + timestamp: T0, + segments: [makeSeg({ segmentId: 'S-hook' })], + }), + ], + }); + + // Simulate override scenario: evalCat is 'maine-coon-override' (not the domain default) + const result = await produceSegmentJudgments( + { traceStore: store }, + { + snapshot: makeSnapshot({ startMs: WINDOW_START, endMs: WINDOW_END }), + evalCat: 'maine-coon-override', + threadIds: ['thread-1'], + }, + ); + + assert.equal(result[0].producedBy.evalCat, 'maine-coon-override'); + assert.equal(result[0].producedBy.domainId, 'eval:harness-ledger'); + }); + }); +}); diff --git a/packages/api/test/harness-eval/skip-reason-eligibility.test.js b/packages/api/test/harness-eval/skip-reason-eligibility.test.js new file mode 100644 index 0000000000..c0914f27f3 --- /dev/null +++ b/packages/api/test/harness-eval/skip-reason-eligibility.test.js @@ -0,0 +1,1171 @@ +/** + * F257 V2 — skip-reason eligibility registry + escalation filter tests. + * + * Sol R2 fixes: + * P1-1: truncation → always conservative-true (unscanned tail may be eligible) + * P1-2: byReason null-prototype (prototype pollution prevention) + * P2-1: producer exhaustiveness (queue_pending removed from union, satisfies) + * P2-2: committed bundle/provenance tests via generator adapter + * P2-3: real append→hook integration test + * + * Sol R3 fixes: + * P1-1: claim lifecycle — uncertainty_probe (1h) vs confirmed (7d) separation; + * truncation-only claims don't suppress subsequent real harm + * P2-1: synthetic pingpong_streak reason bound to producer type + * + * [宪宪/claude-opus-4-6🐾] + */ + +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it, mock } from 'node:test'; +import { + checkGuardThreshold, + createThresholdEscalationHook, +} from '../../dist/infrastructure/harness-eval/guard-threshold-escalation.js'; +import { produceHarnessLedgerRunSnapshot } from '../../dist/infrastructure/harness-eval/harness-ledger-snapshot-provider.js'; +import { + isEscalationEligible, + SKIP_REASON_ELIGIBILITY, + skipReasonCategory, +} from '../../dist/infrastructure/harness-eval/skip-reason-eligibility.js'; +import { createFakeEventSource, rawEvent, T, triggerSuccess } from './_guard-test-helpers.js'; + +// --------------------------------------------------------------------------- +// 1. Registry unit tests (P2-2 classifications + P3-1 deep freeze) +// --------------------------------------------------------------------------- + +describe('skip-reason eligibility registry', () => { + it('dedup_active is NOT eligible for escalation', () => { + assert.equal(isEscalationEligible('dedup_active'), false); + }); + + it('aborted is NOT eligible for escalation', () => { + assert.equal(isEscalationEligible('aborted'), false); + }); + + it('depth IS eligible for escalation (chain safety guard)', () => { + assert.equal(isEscalationEligible('depth'), true); + }); + + it('pingpong_streak IS eligible for escalation', () => { + assert.equal(isEscalationEligible('pingpong_streak'), true); + }); + + it('unknown reason defaults to eligible (fail-closed)', () => { + assert.equal(isEscalationEligible('some_future_reason'), true); + }); + + it('undefined/missing reason defaults to eligible (fail-closed)', () => { + assert.equal(isEscalationEligible(undefined), true); + }); + + it('empty string defaults to eligible (fail-closed)', () => { + assert.equal(isEscalationEligible(''), true); + }); + + it('queue_pending is NOT registered (dead letter — no production emit point)', () => { + assert.equal(Object.hasOwn(SKIP_REASON_ELIGIBILITY, 'queue_pending'), false); + // Falls through to unknown → eligible (fail-closed) + assert.equal(isEscalationEligible('queue_pending'), true); + }); + + it('prototype keys are not eligible entries', () => { + assert.equal(Object.hasOwn(SKIP_REASON_ELIGIBILITY, 'toString'), false); + assert.equal(Object.hasOwn(SKIP_REASON_ELIGIBILITY, 'constructor'), false); + assert.equal(Object.hasOwn(SKIP_REASON_ELIGIBILITY, '__proto__'), false); + }); + + // P3-1: deep freeze + it('entries are deeply frozen (sol R1 P3-1)', () => { + const entry = SKIP_REASON_ELIGIBILITY.dedup_active; + assert.ok(Object.isFrozen(entry), 'entry object must be frozen'); + assert.throws( + () => { + /** @type {any} */ (entry).eligible = true; + }, + TypeError, + 'mutating frozen entry must throw in strict mode', + ); + }); +}); + +describe('skipReasonCategory (P2-2 producer semantics)', () => { + it('dedup_active → delivery_dedup', () => { + assert.equal(skipReasonCategory('dedup_active'), 'delivery_dedup'); + }); + + it('depth → safety_guard (chain safety limit, not capacity)', () => { + assert.equal(skipReasonCategory('depth'), 'safety_guard'); + }); + + it('pingpong_streak → safety_guard', () => { + assert.equal(skipReasonCategory('pingpong_streak'), 'safety_guard'); + }); + + it('aborted → abort', () => { + assert.equal(skipReasonCategory('aborted'), 'abort'); + }); + + it('unknown → unknown', () => { + assert.equal(skipReasonCategory('mystery_reason'), 'unknown'); + }); +}); + +// --------------------------------------------------------------------------- +// 2. Escalation integration — current event IN log (production parity) +// --------------------------------------------------------------------------- + +describe('escalation eligibility filter — dedup_active (sol verdict, real append)', () => { + it('3 dedup_active events in log do NOT trigger escalation', async () => { + // P2-3: current event IS in the seeded log (production: append writes + // to ZSET, then postAppendHook fires with the same event). + const currentEvent = rawEvent({ + timestamp: T + 240_000, + seq: 2, + guardId: 'a2a_route_decision_skip', + normalizedReason: 'dedup_active', + }); + const events = [ + rawEvent({ + timestamp: T, + seq: 0, + guardId: 'a2a_route_decision_skip', + normalizedReason: 'dedup_active', + }), + rawEvent({ + timestamp: T + 120_000, + seq: 1, + guardId: 'a2a_route_decision_skip', + normalizedReason: 'dedup_active', + }), + currentEvent, + ]; + const { redis, guardRejectionLog } = await createFakeEventSource(events); + const triggerEval = mock.fn(async () => triggerSuccess()); + + const result = await checkGuardThreshold(currentEvent, { redis, guardRejectionLog, triggerEval }); + + assert.equal(result.thresholdMet, false, 'dedup_active must NOT meet threshold'); + assert.equal(result.escalated, false, 'must NOT escalate'); + assert.equal(result.episodeCount, 0, 'eligible episode count must be 0'); + assert.equal(triggerEval.mock.callCount(), 0, 'triggerEval must NOT be called'); + }); + + it('3 eligible (depth) events in log DO trigger escalation', async () => { + const currentEvent = rawEvent({ + timestamp: T + 240_000, + seq: 2, + guardId: 'a2a_route_decision_skip', + normalizedReason: 'depth', + }); + const events = [ + rawEvent({ + timestamp: T, + seq: 0, + guardId: 'a2a_route_decision_skip', + normalizedReason: 'depth', + }), + rawEvent({ + timestamp: T + 120_000, + seq: 1, + guardId: 'a2a_route_decision_skip', + normalizedReason: 'depth', + }), + currentEvent, + ]; + const { redis, guardRejectionLog } = await createFakeEventSource(events); + const triggerEval = mock.fn(async () => triggerSuccess()); + + const result = await checkGuardThreshold(currentEvent, { redis, guardRejectionLog, triggerEval }); + + assert.equal(result.thresholdMet, true, 'eligible events must meet threshold'); + assert.equal(result.escalationKind, 'confirmed', 'episodeCount >= threshold → confirmed'); + assert.equal(result.escalated, true, 'must escalate'); + assert.equal(triggerEval.mock.callCount(), 1, 'triggerEval must be called once'); + // Sol R5 P2: seam-level — verify escalationKind actually reaches triggerEval args + const triggerArgs = triggerEval.mock.calls[0].arguments[0]; + assert.equal(triggerArgs.escalationKind, 'confirmed', 'triggerEval receives confirmed escalationKind'); + }); + + it('mixed: 5 dedup_active + 3 eligible (in log) → DOES escalate', async () => { + const currentEvent = rawEvent({ + timestamp: T + 840_000, + seq: 7, + guardId: 'a2a_route_decision_skip', + normalizedReason: 'depth', + }); + const events = [ + rawEvent({ timestamp: T, seq: 0, guardId: 'a2a_route_decision_skip', normalizedReason: 'dedup_active' }), + rawEvent({ + timestamp: T + 120_000, + seq: 1, + guardId: 'a2a_route_decision_skip', + normalizedReason: 'dedup_active', + }), + rawEvent({ + timestamp: T + 240_000, + seq: 2, + guardId: 'a2a_route_decision_skip', + normalizedReason: 'dedup_active', + }), + rawEvent({ + timestamp: T + 360_000, + seq: 3, + guardId: 'a2a_route_decision_skip', + normalizedReason: 'dedup_active', + }), + rawEvent({ + timestamp: T + 480_000, + seq: 4, + guardId: 'a2a_route_decision_skip', + normalizedReason: 'dedup_active', + }), + rawEvent({ timestamp: T + 600_000, seq: 5, guardId: 'a2a_route_decision_skip', normalizedReason: 'depth' }), + rawEvent({ timestamp: T + 720_000, seq: 6, guardId: 'a2a_route_decision_skip', normalizedReason: 'depth' }), + currentEvent, + ]; + const { redis, guardRejectionLog } = await createFakeEventSource(events); + const triggerEval = mock.fn(async () => triggerSuccess()); + + const result = await checkGuardThreshold(currentEvent, { redis, guardRejectionLog, triggerEval }); + + assert.equal(result.thresholdMet, true, '3 eligible episodes (depth) meet threshold'); + assert.equal(result.escalationKind, 'confirmed', 'episodeCount >= threshold → confirmed'); + assert.equal(result.escalated, true, 'must escalate'); + assert.equal(triggerEval.mock.callCount(), 1); + }); + + it('mixed: 5 dedup_active + 2 eligible (in log) → does NOT escalate', async () => { + const currentEvent = rawEvent({ + timestamp: T + 720_000, + seq: 6, + guardId: 'a2a_route_decision_skip', + normalizedReason: 'depth', + }); + const events = [ + rawEvent({ timestamp: T, seq: 0, guardId: 'a2a_route_decision_skip', normalizedReason: 'dedup_active' }), + rawEvent({ + timestamp: T + 120_000, + seq: 1, + guardId: 'a2a_route_decision_skip', + normalizedReason: 'dedup_active', + }), + rawEvent({ + timestamp: T + 240_000, + seq: 2, + guardId: 'a2a_route_decision_skip', + normalizedReason: 'dedup_active', + }), + rawEvent({ + timestamp: T + 360_000, + seq: 3, + guardId: 'a2a_route_decision_skip', + normalizedReason: 'dedup_active', + }), + rawEvent({ + timestamp: T + 480_000, + seq: 4, + guardId: 'a2a_route_decision_skip', + normalizedReason: 'dedup_active', + }), + rawEvent({ timestamp: T + 600_000, seq: 5, guardId: 'a2a_route_decision_skip', normalizedReason: 'depth' }), + currentEvent, + ]; + const { redis, guardRejectionLog } = await createFakeEventSource(events); + const triggerEval = mock.fn(async () => triggerSuccess()); + + const result = await checkGuardThreshold(currentEvent, { redis, guardRejectionLog, triggerEval }); + + assert.equal(result.episodeCount, 2, 'only 2 eligible episodes'); + assert.equal(result.thresholdMet, false, 'below threshold'); + assert.equal(result.escalated, false); + assert.equal(triggerEval.mock.callCount(), 0); + }); +}); + +// --------------------------------------------------------------------------- +// 3. Hard-cap three-state (sol R2 P1-1: truncation = always conservative-true) +// --------------------------------------------------------------------------- + +describe('hard-cap + eligibility filter (sol R2 P1-1)', () => { + it('10,001 dedup_active events hitting hard cap DO escalate (conservative-true)', async () => { + // Sol R2 P1-1: truncation means unscanned tail may contain eligible events. + // Conservative-true: false positive (one eval run) is bounded and acceptable; + // eval cat sees all-dedup_active byReason and correctly self-determines. + const events = Array.from({ length: 10_001 }, (_, i) => + rawEvent({ + timestamp: T + i, + seq: i, + eventId: `dedup-cap-${i}`, + guardId: 'a2a_route_decision_skip', + normalizedReason: 'dedup_active', + }), + ); + const { redis, guardRejectionLog } = await createFakeEventSource(events); + const triggerEval = mock.fn(async () => triggerSuccess()); + + const result = await checkGuardThreshold(events[events.length - 1], { + redis, + guardRejectionLog, + triggerEval, + }); + + assert.equal(result.truncated, true, 'hard cap hit'); + assert.equal(result.thresholdMet, true, 'truncated → conservative-true (unscanned tail may be eligible)'); + assert.equal(result.escalationKind, 'uncertainty_probe', 'truncation-only → uncertainty_probe (Fable ruling)'); + assert.equal(result.escalated, true, 'must escalate (eval cat has byReason to self-determine)'); + }); + + it('10,001 eligible events hitting hard cap DO escalate', async () => { + const events = Array.from({ length: 10_001 }, (_, i) => + rawEvent({ + timestamp: T + i, + seq: i, + eventId: `elig-cap-${i}`, + guardId: 'hold_ball_rate_limit', + }), + ); + const { redis, guardRejectionLog } = await createFakeEventSource(events); + const triggerEval = mock.fn(async () => triggerSuccess()); + + const result = await checkGuardThreshold(events[events.length - 1], { + redis, + guardRejectionLog, + triggerEval, + }); + + assert.equal(result.truncated, true, 'hard cap hit'); + assert.equal(result.thresholdMet, true, 'eligible cap → conservative-true'); + assert.equal(result.escalationKind, 'uncertainty_probe', 'cap with episodeCount < threshold → uncertainty_probe'); + assert.equal(result.escalated, true, 'must escalate'); + }); + + it('mixed at cap: 10k dedup_active + 3 depth (in tail) → conservative-true', async () => { + // Sol R2 P1-1: the key scenario — cap cuts scan before reaching the + // eligible tail. Conservative-true ensures these 3 depth events don't + // silently become a false negative. + const events = [ + ...Array.from({ length: 10_000 }, (_, i) => + rawEvent({ + timestamp: T + i, + seq: i, + eventId: `dedup-mixed-${i}`, + guardId: 'a2a_route_decision_skip', + normalizedReason: 'dedup_active', + }), + ), + // These 3 depth events are in the log but beyond the hard cap scan boundary + rawEvent({ + timestamp: T + 120_000, + seq: 10000, + eventId: 'depth-tail-0', + guardId: 'a2a_route_decision_skip', + normalizedReason: 'depth', + }), + rawEvent({ + timestamp: T + 240_000, + seq: 10001, + eventId: 'depth-tail-1', + guardId: 'a2a_route_decision_skip', + normalizedReason: 'depth', + }), + rawEvent({ + timestamp: T + 360_000, + seq: 10002, + eventId: 'depth-tail-2', + guardId: 'a2a_route_decision_skip', + normalizedReason: 'depth', + }), + ]; + const { redis, guardRejectionLog } = await createFakeEventSource(events); + const triggerEval = mock.fn(async () => triggerSuccess()); + + const result = await checkGuardThreshold(events[events.length - 1], { + redis, + guardRejectionLog, + triggerEval, + }); + + assert.equal(result.truncated, true, 'hard cap hit'); + assert.equal(result.thresholdMet, true, 'conservative-true: unscanned tail has eligible events'); + assert.equal(result.escalationKind, 'uncertainty_probe', 'truncation before threshold → uncertainty_probe'); + assert.equal(result.escalated, true, 'must escalate — false negative here would be a safety gap'); + }); +}); + +// --------------------------------------------------------------------------- +// 4. Non-regression: hold_ball and pingpong still escalate +// --------------------------------------------------------------------------- + +describe('escalation non-regression — hold_ball and pingpong', () => { + it('hold_ball_rate_limit events still escalate', async () => { + const currentEvent = rawEvent({ timestamp: T + 240_000, seq: 2, guardId: 'hold_ball_rate_limit' }); + const events = [ + rawEvent({ timestamp: T, seq: 0, guardId: 'hold_ball_rate_limit' }), + rawEvent({ timestamp: T + 120_000, seq: 1, guardId: 'hold_ball_rate_limit' }), + currentEvent, + ]; + const { redis, guardRejectionLog } = await createFakeEventSource(events); + const triggerEval = mock.fn(async () => triggerSuccess()); + + const result = await checkGuardThreshold(currentEvent, { redis, guardRejectionLog, triggerEval }); + + assert.equal(result.thresholdMet, true, 'hold_ball must still meet threshold'); + assert.equal(result.escalationKind, 'confirmed', 'hold_ball 3 episodes → confirmed'); + assert.equal(result.escalated, true, 'hold_ball must still escalate'); + }); + + it('a2a_block_pingpong events still escalate', async () => { + const currentEvent = rawEvent({ + timestamp: T + 240_000, + seq: 2, + guardId: 'a2a_block_pingpong', + normalizedReason: 'pingpong_streak', + }); + const events = [ + rawEvent({ timestamp: T, seq: 0, guardId: 'a2a_block_pingpong', normalizedReason: 'pingpong_streak' }), + rawEvent({ + timestamp: T + 120_000, + seq: 1, + guardId: 'a2a_block_pingpong', + normalizedReason: 'pingpong_streak', + }), + currentEvent, + ]; + const { redis, guardRejectionLog } = await createFakeEventSource(events); + const triggerEval = mock.fn(async () => triggerSuccess()); + + const result = await checkGuardThreshold(currentEvent, { redis, guardRejectionLog, triggerEval }); + + assert.equal(result.thresholdMet, true, 'pingpong must still meet threshold'); + assert.equal(result.escalationKind, 'confirmed', 'pingpong 3 episodes → confirmed'); + assert.equal(result.escalated, true, 'pingpong must still escalate'); + }); +}); + +// --------------------------------------------------------------------------- +// 5. Snapshot byReason + sourceThreadId (P2-1 provenance) +// --------------------------------------------------------------------------- + +describe('snapshot byReason breakdown (sol R1 P2-1)', () => { + const NOW = Date.now(); + + it('snapshot includes per-reason count, category, and eligibility', async () => { + const events = [ + rawEvent({ + timestamp: NOW - 3000, + seq: 0, + normalizedReason: 'dedup_active', + guardId: 'a2a_route_decision_skip', + }), + rawEvent({ + timestamp: NOW - 2000, + seq: 1, + normalizedReason: 'dedup_active', + guardId: 'a2a_route_decision_skip', + }), + rawEvent({ timestamp: NOW - 1000, seq: 2, normalizedReason: 'depth', guardId: 'a2a_route_decision_skip' }), + ]; + const { guardRejectionLog } = await createFakeEventSource(events); + const root = mkdtempSync(join(tmpdir(), 'f257-byreason-')); + + const result = await produceHarnessLedgerRunSnapshot({ + guardRejectionLog, + harnessFeedbackRoot: root, + ownerUserId: 'user_1', + }); + + assert.ok(result.snapshot.byReason, 'byReason must be present'); + assert.equal(result.snapshot.byReason.dedup_active.count, 2); + assert.equal(result.snapshot.byReason.dedup_active.eligible, false); + assert.equal(result.snapshot.byReason.dedup_active.category, 'delivery_dedup'); + assert.equal(result.snapshot.byReason.depth.count, 1); + assert.equal(result.snapshot.byReason.depth.eligible, true); + assert.equal(result.snapshot.byReason.depth.category, 'safety_guard'); + + // Persisted JSON round-trip: null-prototype → regular object after parse, + // so compare individual entries (deepStrictEqual checks prototype chain). + const persisted = JSON.parse(readFileSync(result.storagePath, 'utf8')); + assert.equal(persisted.byReason.dedup_active.count, 2, 'persisted dedup_active count'); + assert.equal(persisted.byReason.dedup_active.eligible, false, 'persisted dedup_active eligible'); + assert.equal(persisted.byReason.depth.count, 1, 'persisted depth count'); + assert.equal(persisted.byReason.depth.eligible, true, 'persisted depth eligible'); + }); + + it('sourceThreadId persisted in snapshot when provided', async () => { + const events = [rawEvent({ timestamp: NOW - 1000, seq: 0 })]; + const { guardRejectionLog } = await createFakeEventSource(events); + const root = mkdtempSync(join(tmpdir(), 'f257-srcthread-')); + + const result = await produceHarnessLedgerRunSnapshot({ + guardRejectionLog, + harnessFeedbackRoot: root, + ownerUserId: 'user_1', + sourceThreadId: 'thread_abc123', + }); + + assert.equal(result.snapshot.sourceThreadId, 'thread_abc123'); + const persisted = JSON.parse(readFileSync(result.storagePath, 'utf8')); + assert.equal(persisted.sourceThreadId, 'thread_abc123', 'persisted sourceThreadId'); + }); + + it('sourceThreadId absent when not provided (scheduled trigger)', async () => { + const events = [rawEvent({ timestamp: NOW - 1000, seq: 0 })]; + const { guardRejectionLog } = await createFakeEventSource(events); + const root = mkdtempSync(join(tmpdir(), 'f257-nosrc-')); + + const result = await produceHarnessLedgerRunSnapshot({ + guardRejectionLog, + harnessFeedbackRoot: root, + ownerUserId: 'user_1', + }); + + assert.equal(result.snapshot.sourceThreadId, undefined); + const persisted = JSON.parse(readFileSync(result.storagePath, 'utf8')); + assert.equal(persisted.sourceThreadId, undefined, 'no sourceThreadId in persisted'); + }); + + // Sol R4 P1-1: escalationKind propagation through snapshot + it('escalationKind persisted in snapshot when provided (uncertainty_probe)', async () => { + const events = [rawEvent({ timestamp: NOW - 1000, seq: 0 })]; + const { guardRejectionLog } = await createFakeEventSource(events); + const root = mkdtempSync(join(tmpdir(), 'f257-escKind-probe-')); + + const result = await produceHarnessLedgerRunSnapshot({ + guardRejectionLog, + harnessFeedbackRoot: root, + ownerUserId: 'user_1', + escalationKind: 'uncertainty_probe', + }); + + assert.equal(result.snapshot.escalationKind, 'uncertainty_probe'); + const persisted = JSON.parse(readFileSync(result.storagePath, 'utf8')); + assert.equal(persisted.escalationKind, 'uncertainty_probe', 'persisted escalationKind'); + }); + + it('escalationKind persisted in snapshot when provided (confirmed)', async () => { + const events = [rawEvent({ timestamp: NOW - 1000, seq: 0 })]; + const { guardRejectionLog } = await createFakeEventSource(events); + const root = mkdtempSync(join(tmpdir(), 'f257-escKind-confirmed-')); + + const result = await produceHarnessLedgerRunSnapshot({ + guardRejectionLog, + harnessFeedbackRoot: root, + ownerUserId: 'user_1', + escalationKind: 'confirmed', + }); + + assert.equal(result.snapshot.escalationKind, 'confirmed'); + const persisted = JSON.parse(readFileSync(result.storagePath, 'utf8')); + assert.equal(persisted.escalationKind, 'confirmed', 'persisted escalationKind'); + }); + + it('escalationKind absent when not provided (manual/scheduled trigger)', async () => { + const events = [rawEvent({ timestamp: NOW - 1000, seq: 0 })]; + const { guardRejectionLog } = await createFakeEventSource(events); + const root = mkdtempSync(join(tmpdir(), 'f257-escKind-absent-')); + + const result = await produceHarnessLedgerRunSnapshot({ + guardRejectionLog, + harnessFeedbackRoot: root, + ownerUserId: 'user_1', + }); + + assert.equal(result.snapshot.escalationKind, undefined); + const persisted = JSON.parse(readFileSync(result.storagePath, 'utf8')); + assert.equal(persisted.escalationKind, undefined, 'no escalationKind in persisted'); + }); + + it('uncertainty_probe summary includes UNCERTAINTY PROBE warning', async () => { + const events = [rawEvent({ timestamp: NOW - 1000, seq: 0 })]; + const { guardRejectionLog } = await createFakeEventSource(events); + const root = mkdtempSync(join(tmpdir(), 'f257-escKind-summary-')); + + const result = await produceHarnessLedgerRunSnapshot({ + guardRejectionLog, + harnessFeedbackRoot: root, + ownerUserId: 'user_1', + escalationKind: 'uncertainty_probe', + }); + + assert.ok(result.summary.includes('UNCERTAINTY PROBE'), 'summary includes uncertainty probe warning'); + assert.ok(result.summary.includes('truncation'), 'summary mentions truncation cause'); + }); + + // Sol R2 P1-2: prototype pollution regression + it('byReason aggregation is prototype-safe (__proto__ / constructor / toString)', async () => { + const events = [ + rawEvent({ timestamp: NOW - 3000, seq: 0, normalizedReason: '__proto__', guardId: 'a2a_route_decision_skip' }), + rawEvent({ timestamp: NOW - 2000, seq: 1, normalizedReason: 'constructor', guardId: 'a2a_route_decision_skip' }), + rawEvent({ timestamp: NOW - 1000, seq: 2, normalizedReason: 'toString', guardId: 'a2a_route_decision_skip' }), + ]; + const { guardRejectionLog } = await createFakeEventSource(events); + const root = mkdtempSync(join(tmpdir(), 'f257-proto-')); + + // Before fix: byReason['__proto__'] would pollute Object.prototype + const savedProtoCount = Object.prototype.count; + const result = await produceHarnessLedgerRunSnapshot({ + guardRejectionLog, + harnessFeedbackRoot: root, + ownerUserId: 'user_1', + }); + + // Verify no prototype pollution + assert.equal(Object.prototype.count, savedProtoCount, 'Object.prototype.count must NOT be polluted'); + + // Verify the entries are correctly stored as own properties. + // Use Object.hasOwn + direct access to avoid biome's useLiteralKeys + // on __proto__ (dot-access would invoke the prototype getter). + const br = result.snapshot.byReason; + assert.ok(br, 'byReason must be present'); + assert.ok(Object.hasOwn(br, '__proto__'), '__proto__ is own property'); + assert.equal(Reflect.get(br, '__proto__')?.count, 1, '__proto__ reason stored as own property'); + assert.ok(Object.hasOwn(br, 'constructor'), 'constructor is own property'); + assert.equal(Reflect.get(br, 'constructor')?.count, 1, 'constructor reason stored'); + assert.ok(Object.hasOwn(br, 'toString'), 'toString is own property'); + assert.equal(Reflect.get(br, 'toString')?.count, 1, 'toString reason stored'); + + // Verify JSON round-trip preserves all entries + const persisted = JSON.parse(readFileSync(result.storagePath, 'utf8')); + assert.ok(Object.hasOwn(persisted.byReason, '__proto__'), '__proto__ survives JSON round-trip'); + assert.equal(Reflect.get(persisted.byReason, '__proto__')?.count, 1, '__proto__ count round-trip'); + assert.equal(Reflect.get(persisted.byReason, 'constructor')?.count, 1, 'constructor count round-trip'); + }); +}); + +// --------------------------------------------------------------------------- +// 6. Sol R2 P2-2: committed bundle/provenance via generator adapter +// --------------------------------------------------------------------------- + +describe('committed bundle carries byReason + sourceThreadId (sol R2 P2-2)', () => { + const DEFAULT_WINDOW_START = 1700000000000; + const DEFAULT_WINDOW_END = 1700604800000; + let evalRunCounter = 100; + + function safeEvalRunId() { + return `hlr-${1700000000000 + evalRunCounter++}-a1b2c3d4`; + } + + function writeSnapshotFile(rootDir, evalRunId, overrides = {}) { + const dir = join(rootDir, 'run-snapshots'); + mkdirSync(dir, { recursive: true }); + const snapshot = { + evalRunId, + producedAt: new Date().toISOString(), + ownerUserId: 'user_1', + window: { startMs: DEFAULT_WINDOW_START, endMs: DEFAULT_WINDOW_END, durationHours: 168 }, + totalEvents: 3, + byKind: { route_decision_skip: 3 }, + byGuard: { a2a_route_decision_skip: { count: 3, kinds: ['route_decision_skip'], episodeCount: 1, episodes: [] } }, + sampleAnchors: [], + howCounted: 'zset-window-scan', + truncated: false, + ...overrides, + }; + writeFileSync(join(dir, `${evalRunId}.json`), JSON.stringify(snapshot, null, 2)); + return snapshot; + } + + it('bundle snapshot.json carries byReason from stored snapshot', async () => { + const { createHarnessLedgerGeneratorAdapter } = await import( + '../../dist/infrastructure/harness-eval/publish-verdict/harness-ledger-generator-adapter.js' + ); + const generator = createHarnessLedgerGeneratorAdapter(); + const tmpDir = mkdtempSync(join(tmpdir(), 'f257-bundle-byreason-')); + const evalRunId = safeEvalRunId(); + const packet = { id: 'byreason-bundle-test', domainId: 'eval:harness-ledger' }; + + writeSnapshotFile(tmpDir, evalRunId, { + byReason: { + dedup_active: { count: 2, category: 'delivery_dedup', eligible: false }, + depth: { count: 1, category: 'safety_guard', eligible: true }, + }, + }); + + const result = await generator( + packet, + { kind: 'prompt-segments', windowStartMs: DEFAULT_WINDOW_START, windowEndMs: DEFAULT_WINDOW_END, evalRunId }, + { harnessFeedbackRoot: tmpDir, liveHarnessFeedbackRoot: tmpDir, ownerUserId: 'user_1' }, + ); + + const bundleSnapshot = JSON.parse(readFileSync(join(result.bundleDir, 'snapshot.json'), 'utf8')); + assert.ok(bundleSnapshot.byReason, 'bundle snapshot must contain byReason'); + assert.equal(bundleSnapshot.byReason.dedup_active.count, 2); + assert.equal(bundleSnapshot.byReason.dedup_active.eligible, false); + assert.equal(bundleSnapshot.byReason.depth.count, 1); + assert.equal(bundleSnapshot.byReason.depth.eligible, true); + }); + + it('bundle snapshot.json omits byReason when not in stored snapshot (backward compat)', async () => { + const { createHarnessLedgerGeneratorAdapter } = await import( + '../../dist/infrastructure/harness-eval/publish-verdict/harness-ledger-generator-adapter.js' + ); + const generator = createHarnessLedgerGeneratorAdapter(); + const tmpDir = mkdtempSync(join(tmpdir(), 'f257-bundle-nobyreason-')); + const evalRunId = safeEvalRunId(); + const packet = { id: 'nobyreason-bundle-test', domainId: 'eval:harness-ledger' }; + + // No byReason in stored snapshot — pre-classification snapshots + writeSnapshotFile(tmpDir, evalRunId); + + const result = await generator( + packet, + { kind: 'prompt-segments', windowStartMs: DEFAULT_WINDOW_START, windowEndMs: DEFAULT_WINDOW_END, evalRunId }, + { harnessFeedbackRoot: tmpDir, liveHarnessFeedbackRoot: tmpDir, ownerUserId: 'user_1' }, + ); + + const bundleSnapshot = JSON.parse(readFileSync(join(result.bundleDir, 'snapshot.json'), 'utf8')); + assert.equal(bundleSnapshot.byReason, undefined, 'byReason absent when not in stored snapshot'); + }); + + it('provenance.json carries sourceThreadId from stored snapshot', async () => { + const { createHarnessLedgerGeneratorAdapter } = await import( + '../../dist/infrastructure/harness-eval/publish-verdict/harness-ledger-generator-adapter.js' + ); + const generator = createHarnessLedgerGeneratorAdapter(); + const tmpDir = mkdtempSync(join(tmpdir(), 'f257-prov-srcthread-')); + const evalRunId = safeEvalRunId(); + const packet = { id: 'srcthread-prov-test', domainId: 'eval:harness-ledger' }; + + writeSnapshotFile(tmpDir, evalRunId, { sourceThreadId: 'thread_xyz789' }); + + const result = await generator( + packet, + { kind: 'prompt-segments', windowStartMs: DEFAULT_WINDOW_START, windowEndMs: DEFAULT_WINDOW_END, evalRunId }, + { harnessFeedbackRoot: tmpDir, liveHarnessFeedbackRoot: tmpDir, ownerUserId: 'user_1' }, + ); + + const provenance = JSON.parse(readFileSync(join(result.bundleDir, 'provenance.json'), 'utf8')); + assert.equal(provenance.producedBy.runId, evalRunId); + assert.equal(provenance.producedBy.sourceThreadId, 'thread_xyz789', 'sourceThreadId in provenance'); + }); + + it('provenance.json omits sourceThreadId when absent (scheduled trigger)', async () => { + const { createHarnessLedgerGeneratorAdapter } = await import( + '../../dist/infrastructure/harness-eval/publish-verdict/harness-ledger-generator-adapter.js' + ); + const generator = createHarnessLedgerGeneratorAdapter(); + const tmpDir = mkdtempSync(join(tmpdir(), 'f257-prov-nosrcthread-')); + const evalRunId = safeEvalRunId(); + const packet = { id: 'nosrcthread-prov-test', domainId: 'eval:harness-ledger' }; + + // No sourceThreadId in stored snapshot + writeSnapshotFile(tmpDir, evalRunId); + + const result = await generator( + packet, + { kind: 'prompt-segments', windowStartMs: DEFAULT_WINDOW_START, windowEndMs: DEFAULT_WINDOW_END, evalRunId }, + { harnessFeedbackRoot: tmpDir, liveHarnessFeedbackRoot: tmpDir, ownerUserId: 'user_1' }, + ); + + const provenance = JSON.parse(readFileSync(join(result.bundleDir, 'provenance.json'), 'utf8')); + assert.equal(provenance.producedBy.runId, evalRunId); + assert.equal(provenance.producedBy.sourceThreadId, undefined, 'no sourceThreadId when absent'); + }); + + // Sol R4 P1-1: escalationKind propagation through bundle provenance + it('provenance.json carries escalationKind from stored snapshot (uncertainty_probe)', async () => { + const { createHarnessLedgerGeneratorAdapter } = await import( + '../../dist/infrastructure/harness-eval/publish-verdict/harness-ledger-generator-adapter.js' + ); + const generator = createHarnessLedgerGeneratorAdapter(); + const tmpDir = mkdtempSync(join(tmpdir(), 'f257-prov-escKind-probe-')); + const evalRunId = safeEvalRunId(); + const packet = { id: 'escKind-probe-prov-test', domainId: 'eval:harness-ledger' }; + + writeSnapshotFile(tmpDir, evalRunId, { escalationKind: 'uncertainty_probe' }); + + const result = await generator( + packet, + { kind: 'prompt-segments', windowStartMs: DEFAULT_WINDOW_START, windowEndMs: DEFAULT_WINDOW_END, evalRunId }, + { harnessFeedbackRoot: tmpDir, liveHarnessFeedbackRoot: tmpDir, ownerUserId: 'user_1' }, + ); + + const provenance = JSON.parse(readFileSync(join(result.bundleDir, 'provenance.json'), 'utf8')); + assert.equal(provenance.producedBy.runId, evalRunId); + assert.equal(provenance.producedBy.escalationKind, 'uncertainty_probe', 'escalationKind in provenance'); + }); + + it('provenance.json carries escalationKind from stored snapshot (confirmed)', async () => { + const { createHarnessLedgerGeneratorAdapter } = await import( + '../../dist/infrastructure/harness-eval/publish-verdict/harness-ledger-generator-adapter.js' + ); + const generator = createHarnessLedgerGeneratorAdapter(); + const tmpDir = mkdtempSync(join(tmpdir(), 'f257-prov-escKind-confirmed-')); + const evalRunId = safeEvalRunId(); + const packet = { id: 'escKind-confirmed-prov-test', domainId: 'eval:harness-ledger' }; + + writeSnapshotFile(tmpDir, evalRunId, { escalationKind: 'confirmed' }); + + const result = await generator( + packet, + { kind: 'prompt-segments', windowStartMs: DEFAULT_WINDOW_START, windowEndMs: DEFAULT_WINDOW_END, evalRunId }, + { harnessFeedbackRoot: tmpDir, liveHarnessFeedbackRoot: tmpDir, ownerUserId: 'user_1' }, + ); + + const provenance = JSON.parse(readFileSync(join(result.bundleDir, 'provenance.json'), 'utf8')); + assert.equal(provenance.producedBy.runId, evalRunId); + assert.equal(provenance.producedBy.escalationKind, 'confirmed', 'escalationKind in provenance'); + }); + + it('provenance.json omits escalationKind when absent (manual/scheduled trigger)', async () => { + const { createHarnessLedgerGeneratorAdapter } = await import( + '../../dist/infrastructure/harness-eval/publish-verdict/harness-ledger-generator-adapter.js' + ); + const generator = createHarnessLedgerGeneratorAdapter(); + const tmpDir = mkdtempSync(join(tmpdir(), 'f257-prov-noEscKind-')); + const evalRunId = safeEvalRunId(); + const packet = { id: 'noEscKind-prov-test', domainId: 'eval:harness-ledger' }; + + // No escalationKind in stored snapshot + writeSnapshotFile(tmpDir, evalRunId); + + const result = await generator( + packet, + { kind: 'prompt-segments', windowStartMs: DEFAULT_WINDOW_START, windowEndMs: DEFAULT_WINDOW_END, evalRunId }, + { harnessFeedbackRoot: tmpDir, liveHarnessFeedbackRoot: tmpDir, ownerUserId: 'user_1' }, + ); + + const provenance = JSON.parse(readFileSync(join(result.bundleDir, 'provenance.json'), 'utf8')); + assert.equal(provenance.producedBy.runId, evalRunId); + assert.equal(provenance.producedBy.escalationKind, undefined, 'no escalationKind when absent'); + }); +}); + +// --------------------------------------------------------------------------- +// 7. Sol R2 P2-3: real append → postAppendHook integration +// --------------------------------------------------------------------------- + +describe('real append → hook with eligibility filter (sol R2 P2-3)', async () => { + const { GuardRejectionEventLog } = await import('../../dist/infrastructure/harness-eval/GuardRejectionEventLog.js'); + + /** Full fake Redis supporting both ZSET (event log) and KV (dedup claim). */ + function createFullFakeRedis() { + const store = new Map(); + const sorted = new Map(); + return { + get: async (key) => store.get(key) ?? null, + set: async (key, value, ...args) => { + const hasNX = args.includes('NX'); + if (hasNX && store.has(key)) return null; + store.set(key, value); + return 'OK'; + }, + del: async (key) => { + const existed = store.has(key); + store.delete(key); + return existed ? 1 : 0; + }, + expire: async () => 1, + zadd: async (key, score, member) => { + const s = sorted.get(key) ?? new Map(); + s.set(member, score); + sorted.set(key, s); + return 1; + }, + zrangebyscore: async (key, min, max, ...args) => { + const s = sorted.get(key); + if (!s) return []; + let offset = 0; + let count = s.size; + for (let i = 0; i < args.length; i++) { + if (String(args[i]).toUpperCase() === 'LIMIT') { + offset = Number(args[i + 1]); + count = Number(args[i + 2]); + break; + } + } + return [...s.entries()] + .filter(([, sc]) => sc >= min && sc <= max) + .sort((a, b) => a[1] - b[1]) + .slice(offset, offset + count) + .map(([m]) => m); + }, + zremrangebyscore: async (key, min, max) => { + const s = sorted.get(key); + if (!s) return 0; + let removed = 0; + for (const [member, score] of s) { + if (score >= min && score <= max) { + s.delete(member); + removed++; + } + } + return removed; + }, + _store: store, + }; + } + + function makeAppendEvent(guardId, timestamp, overrides = {}) { + return { + kind: 'route_decision_skip', + guardId, + threadId: 'thread_append', + catId: 'cat_append', + ownerUserId: 'user_1', + timestamp, + correlationConfidence: 'window', + ...overrides, + }; + } + + it('3rd dedup_active append does NOT trigger escalation (real hook)', async () => { + const redis = createFullFakeRedis(); + const log = new GuardRejectionEventLog(redis); + const triggerEval = mock.fn(async () => triggerSuccess()); + const hook = createThresholdEscalationHook({ redis, guardRejectionLog: log, triggerEval }); + log.setPostAppendHook(hook); + + const now = T; + await log.append(makeAppendEvent('a2a_route_decision_skip', now, { normalizedReason: 'dedup_active' })); + await log.append(makeAppendEvent('a2a_route_decision_skip', now + 120_000, { normalizedReason: 'dedup_active' })); + await log.append(makeAppendEvent('a2a_route_decision_skip', now + 240_000, { normalizedReason: 'dedup_active' })); + await new Promise((r) => setTimeout(r, 80)); + + assert.equal(triggerEval.mock.callCount(), 0, 'dedup_active must NOT trigger escalation via real append'); + }); + + it('3rd eligible (depth) append DOES trigger escalation (real hook)', async () => { + const redis = createFullFakeRedis(); + const log = new GuardRejectionEventLog(redis); + const triggerEval = mock.fn(async () => triggerSuccess()); + const hook = createThresholdEscalationHook({ redis, guardRejectionLog: log, triggerEval }); + log.setPostAppendHook(hook); + + const now = T; + await log.append(makeAppendEvent('a2a_route_decision_skip', now, { normalizedReason: 'depth' })); + await log.append(makeAppendEvent('a2a_route_decision_skip', now + 120_000, { normalizedReason: 'depth' })); + await log.append(makeAppendEvent('a2a_route_decision_skip', now + 240_000, { normalizedReason: 'depth' })); + await new Promise((r) => setTimeout(r, 80)); + + assert.equal(triggerEval.mock.callCount(), 1, 'depth must trigger escalation via real append at 3rd episode'); + }); +}); + +// --------------------------------------------------------------------------- +// 8. Sol R3 P1-1: claim lifecycle — uncertain vs confirmed separation +// --------------------------------------------------------------------------- + +describe('sol R3 P1-1: claim lifecycle — uncertain vs confirmed', () => { + it('uncertainty-probe claim does NOT block confirmed claim (different key namespace)', async () => { + // Phase 2 test: 3 real depth events → confirmed escalation must succeed + // even when an uncertain claim from a prior dedup-cap already exists. + const depthEvents = [ + rawEvent({ + timestamp: T, + seq: 0, + guardId: 'a2a_route_decision_skip', + normalizedReason: 'depth', + }), + rawEvent({ + timestamp: T + 120_000, + seq: 1, + guardId: 'a2a_route_decision_skip', + normalizedReason: 'depth', + }), + rawEvent({ + timestamp: T + 240_000, + seq: 2, + guardId: 'a2a_route_decision_skip', + normalizedReason: 'depth', + }), + ]; + const { redis, guardRejectionLog } = await createFakeEventSource(depthEvents); + const triggerEval = mock.fn(async () => triggerSuccess()); + + // Pre-set uncertain claim (simulates prior truncation-only escalation) + // Sol R4 P2-1: TTL must match production UNCERTAINTY_PROBE_TTL_SECONDS (3600, not 300) + await redis.set( + 'guard-rejection:uncertainty:user_1:a2a_route_decision_skip', + JSON.stringify({ escalatedAt: T - 60_000, escalationKind: 'uncertainty_probe' }), + 'EX', + 3600, + 'NX', + ); + + const result = await checkGuardThreshold(depthEvents[2], { + redis, + guardRejectionLog, + triggerEval, + }); + + assert.equal(result.thresholdMet, true, 'confirmed threshold met'); + assert.equal(result.escalationKind, 'confirmed', 'episodeCount >= threshold → confirmed'); + assert.equal(result.escalated, true, 'confirmed escalation fires despite probe claim'); + assert.equal(result.alreadyEscalated, false, 'NOT blocked — different key namespace'); + assert.equal(triggerEval.mock.callCount(), 1, 'trigger fires for confirmed'); + // Sol R5 P2: seam-level — verify escalationKind reaches triggerEval (confirmed despite prior probe) + const triggerArgs = triggerEval.mock.calls[0].arguments[0]; + assert.equal(triggerArgs.escalationKind, 'confirmed', 'triggerEval receives confirmed (not probe)'); + }); + + it('confirmed claim blocks subsequent uncertainty-probe triggers', async () => { + // When a confirmed 7d claim exists, truncation-only events should NOT + // trigger another eval (real harm was already escalated). + const capEvents = Array.from({ length: 10_001 }, (_, i) => + rawEvent({ + timestamp: T + i, + seq: i, + eventId: `confirm-block-${i}`, + guardId: 'a2a_route_decision_skip', + normalizedReason: 'dedup_active', + }), + ); + const { redis, guardRejectionLog } = await createFakeEventSource(capEvents); + const triggerEval = mock.fn(async () => triggerSuccess()); + + // Pre-set confirmed claim (simulates prior real harm escalation) + await redis.set( + 'guard-rejection:escalated:user_1:a2a_route_decision_skip', + JSON.stringify({ escalatedAt: T - 60_000, escalationKind: 'confirmed' }), + 'EX', + 604800, + 'NX', + ); + + const result = await checkGuardThreshold(capEvents[capEvents.length - 1], { + redis, + guardRejectionLog, + triggerEval, + }); + + assert.equal(result.truncated, true, 'hard cap hit'); + assert.equal(result.escalationKind, 'uncertainty_probe', 'truncation-only → uncertainty_probe kind'); + assert.equal(result.alreadyEscalated, true, 'blocked by existing confirmed claim'); + assert.equal(result.escalated, false, 'no trigger fired'); + assert.equal(triggerEval.mock.callCount(), 0, 'triggerEval NOT called'); + }); + + it('consecutive uncertainty-probe escalations are deduplicated within 1h (anti-storm)', async () => { + // First truncation-only event → uncertain claim → fires trigger. + // Second event with same guard → uncertain NX blocks → no second trigger. + const capEvents = Array.from({ length: 10_001 }, (_, i) => + rawEvent({ + timestamp: T + i, + seq: i, + eventId: `storm-${i}`, + guardId: 'a2a_route_decision_skip', + normalizedReason: 'dedup_active', + }), + ); + const { redis, guardRejectionLog } = await createFakeEventSource(capEvents); + // Spy on redis.set to verify claim parameters + const originalSet = redis.set.bind(redis); + const setCalls = []; + redis.set = async (...args) => { + setCalls.push(args); + return originalSet(...args); + }; + const triggerEval = mock.fn(async () => triggerSuccess()); + + // First call → uncertainty probe fires + const r1 = await checkGuardThreshold(capEvents[capEvents.length - 1], { + redis, + guardRejectionLog, + triggerEval, + }); + assert.equal(r1.escalated, true, 'first uncertainty-probe fires'); + assert.equal(r1.escalationKind, 'uncertainty_probe'); + assert.equal(triggerEval.mock.callCount(), 1, '1 trigger after first call'); + // Sol R5 P2: seam-level — verify escalationKind reaches triggerEval (probe path) + const probeArgs = triggerEval.mock.calls[0].arguments[0]; + assert.equal(probeArgs.escalationKind, 'uncertainty_probe', 'triggerEval receives uncertainty_probe'); + + // Sol R4 P2-1: verify SET parameters — uncertainty key + EX 3600 + NX + const claimSet = setCalls.find((c) => String(c[0]).includes('uncertainty:')); + assert.ok(claimSet, 'SET call must use uncertainty: key prefix'); + assert.ok( + String(claimSet[0]).startsWith('guard-rejection:uncertainty:'), + 'key prefix = guard-rejection:uncertainty:', + ); + assert.equal(claimSet[2], 'EX', 'SET uses EX flag'); + assert.equal(claimSet[3], 3600, 'TTL = 3600 seconds (1h per Fable ruling)'); + assert.equal(claimSet[4], 'NX', 'SET uses NX flag'); + + // Sol R4 P2-1: verify NO escalated: key exists (only uncertainty: key) + const confirmedKey = 'guard-rejection:escalated:user_1:a2a_route_decision_skip'; + const confirmedExists = await redis.get(confirmedKey); + assert.equal(confirmedExists, null, 'dedup-only cap must NOT create escalated: key (Fable invariant)'); + + // Second call (same guard, same event source) → uncertain NX blocks + const event2 = rawEvent({ + timestamp: T + 20_000, + seq: 10002, + eventId: 'storm-repeat', + guardId: 'a2a_route_decision_skip', + normalizedReason: 'dedup_active', + }); + const r2 = await checkGuardThreshold(event2, { + redis, + guardRejectionLog, + triggerEval, + }); + assert.equal(r2.escalationKind, 'uncertainty_probe'); + assert.equal(r2.alreadyEscalated, true, 'second probe blocked by NX'); + assert.equal(r2.escalated, false, 'no second trigger'); + assert.equal(triggerEval.mock.callCount(), 1, 'still only 1 trigger total'); + }); + + it('full two-phase scenario: dedup-cap uncertain → 3 depth confirmed', async () => { + // Phase 1: 10k+ dedup_active → truncated → uncertain escalation + const dedupCapEvents = Array.from({ length: 10_001 }, (_, i) => + rawEvent({ + timestamp: T + i, + seq: i, + eventId: `phase1-${i}`, + guardId: 'a2a_route_decision_skip', + normalizedReason: 'dedup_active', + }), + ); + const { redis: redis1, guardRejectionLog: log1 } = await createFakeEventSource(dedupCapEvents); + const triggerEval = mock.fn(async () => triggerSuccess()); + + const phase1 = await checkGuardThreshold(dedupCapEvents[dedupCapEvents.length - 1], { + redis: redis1, + guardRejectionLog: log1, + triggerEval, + }); + assert.equal(phase1.escalationKind, 'uncertainty_probe', 'Phase 1: uncertainty_probe'); + assert.equal(phase1.escalated, true, 'Phase 1: probe fires'); + + // Phase 2: 3 depth events with the SAME Redis store (claim keys persist) + // but separate event source (simulates passage of time + new events) + const depthEvents = [ + rawEvent({ + timestamp: T + 120_000, + seq: 0, + guardId: 'a2a_route_decision_skip', + normalizedReason: 'depth', + }), + rawEvent({ + timestamp: T + 240_000, + seq: 1, + guardId: 'a2a_route_decision_skip', + normalizedReason: 'depth', + }), + rawEvent({ + timestamp: T + 360_000, + seq: 2, + guardId: 'a2a_route_decision_skip', + normalizedReason: 'depth', + }), + ]; + // Create new event source with depth events but reuse Phase 1's Redis _store + // for claim key persistence (the uncertain key from Phase 1 is in there). + const { guardRejectionLog: log2 } = await createFakeEventSource(depthEvents); + + const phase2 = await checkGuardThreshold(depthEvents[2], { + redis: redis1, + guardRejectionLog: log2, + triggerEval, + }); + assert.equal(phase2.escalationKind, 'confirmed', 'Phase 2: confirmed'); + assert.equal(phase2.escalated, true, 'Phase 2: fires despite Phase 1 probe claim'); + assert.equal(phase2.alreadyEscalated, false, 'Phase 2: NOT blocked'); + assert.equal(triggerEval.mock.callCount(), 2, 'total 2 triggers: 1 probe + 1 confirmed'); + }); +}); diff --git a/packages/api/test/harness-eval/task-outcome-signal-chain-e2e.test.js b/packages/api/test/harness-eval/task-outcome-signal-chain-e2e.test.js index f88afadac7..d9e782b134 100644 --- a/packages/api/test/harness-eval/task-outcome-signal-chain-e2e.test.js +++ b/packages/api/test/harness-eval/task-outcome-signal-chain-e2e.test.js @@ -140,6 +140,62 @@ describe('AC-G11 Task Outcome Signal Chain E2E', () => { // Original type in store is magic_word_ref, but read-side projects to magic_word assert.equal(magicWordSignals[0].type, 'magic_word'); }); + + it('R9: deleted event/thread refs are purged and late projection writers are fenced', () => { + const eventId = 'evt_r9_deleted_ref'; + const threadId = 'thread_r9_deleted_ref'; + const first = appendMagicWordRefToEpisode(store, { + eventId, + word: '脚手架', + threadId, + catId: CAT_ID, + }); + assert.equal(first.signalAppended, true); + + assert.equal(store.deleteMagicWordRefsByEventIds([eventId]), 1); + assert.equal(handleGetEpisode(store, first.episodeId).signals.a2InteractionDecisions.length, 0); + assert.equal( + appendMagicWordRefToEpisode(store, { eventId, word: '脚手架', threadId, catId: CAT_ID }).signalAppended, + false, + ); + assert.throws( + () => + store.appendSignal(first.episodeId, { + category: 'a2', + record: { + type: 'magic_word_ref', + eventId, + word: '脚手架', + timestamp: new Date().toISOString(), + threadId, + catId: CAT_ID, + }, + }), + /deleted magic_word_ref/i, + 'generic sibling writer must not bypass the terminal fence', + ); + + const threadEventId = 'evt_r9_deleted_thread_ref'; + assert.equal( + appendMagicWordRefToEpisode(store, { + eventId: threadEventId, + word: '绕路了', + threadId, + catId: CAT_ID, + }).signalAppended, + true, + ); + assert.equal(store.deleteMagicWordRefsByThread(threadId), 1); + assert.equal( + appendMagicWordRefToEpisode(store, { + eventId: 'evt_r9_late_thread_ref', + word: '绕路了', + threadId, + catId: CAT_ID, + }).signalAppended, + false, + ); + }); }); // ========================================================================= diff --git a/packages/api/test/harness-ledger-generator-adapter.test.js b/packages/api/test/harness-ledger-generator-adapter.test.js new file mode 100644 index 0000000000..b252b245e2 --- /dev/null +++ b/packages/api/test/harness-ledger-generator-adapter.test.js @@ -0,0 +1,489 @@ +/** + * F257 Eval Engine Wiring — harness-ledger generator adapter tests. + * + * KD-17 snapshot-first: adapter reads stored run snapshot by evalRunId + * (no direct GuardRejectionEventLog query). Tests pre-write snapshot files. + */ + +import assert from 'node:assert/strict'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; + +const { createHarnessLedgerGeneratorAdapter } = await import( + '../dist/infrastructure/harness-eval/publish-verdict/harness-ledger-generator-adapter.js' +); + +// ── Test helpers ── + +/** Stable window constants — both helpers use the same values so KD-17 window mismatch check passes. */ +const DEFAULT_WINDOW_START = 1700000000000; +const DEFAULT_WINDOW_END = 1700604800000; // 7 days later (168 hours) + +function makeTmpDir() { + const dir = join(tmpdir(), `hlga-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function makePacket(overrides = {}) { + return { + id: `verdict-${Math.random().toString(36).slice(2, 10)}`, + domainId: 'eval:harness-ledger', + ...overrides, + }; +} + +/** Counter for generating unique but format-valid evalRunIds. */ +let evalRunCounter = 0; +function safeEvalRunId() { + return `hlr-${1700000000000 + evalRunCounter++}-a1b2c3d4`; +} + +function makeSourceRefs(overrides = {}) { + return { + kind: 'prompt-segments', + windowStartMs: DEFAULT_WINDOW_START, + windowEndMs: DEFAULT_WINDOW_END, + evalRunId: safeEvalRunId(), + ...overrides, + }; +} + +function makeDeps(harnessFeedbackRoot, ownerUserId = 'user_1') { + return { + harnessFeedbackRoot, + liveHarnessFeedbackRoot: harnessFeedbackRoot, + ownerUserId, + }; +} + +/** Write a stored run snapshot to the expected filesystem path. */ +function writeRunSnapshot(rootDir, evalRunId, snapshotData = {}) { + const dir = join(rootDir, 'run-snapshots'); + mkdirSync(dir, { recursive: true }); + const snapshot = { + evalRunId, + producedAt: new Date().toISOString(), + ownerUserId: 'user_1', + window: { startMs: DEFAULT_WINDOW_START, endMs: DEFAULT_WINDOW_END, durationHours: 168 }, + totalEvents: 0, + byKind: {}, + byGuard: {}, + sampleAnchors: [], + howCounted: 'zset-window-scan', + ...snapshotData, + }; + writeFileSync(join(dir, `${evalRunId}.json`), JSON.stringify(snapshot, null, 2)); + return snapshot; +} + +describe('harness-ledger-generator-adapter', () => { + test('throws on wrong sourceRefs kind', async () => { + const generator = createHarnessLedgerGeneratorAdapter(); + + await assert.rejects( + () => generator(makePacket(), { kind: 'qc-metrics-rollup' }, makeDeps(makeTmpDir())), + (err) => { + assert.ok(err.message.includes('harness_ledger_adapter_wrong_kind')); + return true; + }, + ); + }); + + test('throws on invalid window (end <= start)', async () => { + const generator = createHarnessLedgerGeneratorAdapter(); + const now = Date.now(); + + await assert.rejects( + () => + generator( + makePacket(), + makeSourceRefs({ windowStartMs: now, windowEndMs: now - 1000 }), + makeDeps(makeTmpDir()), + ), + (err) => { + assert.ok(err.message.includes('invalid_window')); + return true; + }, + ); + }); + + test('throws on non-finite window values', async () => { + const generator = createHarnessLedgerGeneratorAdapter(); + + await assert.rejects( + () => + generator( + makePacket(), + makeSourceRefs({ windowStartMs: Number.NaN, windowEndMs: Date.now() }), + makeDeps(makeTmpDir()), + ), + (err) => { + assert.ok(err.message.includes('invalid_window')); + return true; + }, + ); + }); + + test('throws when evalRunId is missing (KD-17)', async () => { + const generator = createHarnessLedgerGeneratorAdapter(); + + await assert.rejects( + () => + generator( + makePacket(), + { kind: 'prompt-segments', windowStartMs: Date.now() - 1000, windowEndMs: Date.now() }, + makeDeps(makeTmpDir()), + ), + (err) => { + assert.ok(err.message.includes('harness_ledger_adapter_missing_run_id')); + return true; + }, + ); + }); + + test('throws when snapshot file is missing (fail-closed KD-17)', async () => { + const generator = createHarnessLedgerGeneratorAdapter(); + const tmpDir = makeTmpDir(); + + await assert.rejects( + () => generator(makePacket(), makeSourceRefs({ evalRunId: 'hlr-9999999999999-deadbeef' }), makeDeps(tmpDir)), + (err) => { + assert.ok(err.message.includes('harness_ledger_adapter_snapshot_missing')); + return true; + }, + ); + + rmSync(tmpDir, { recursive: true }); + }); + + test('produces zero-event verdict with noFindingRecord', async () => { + const generator = createHarnessLedgerGeneratorAdapter(); + const tmpDir = makeTmpDir(); + const evalRunId = safeEvalRunId(); + const packet = makePacket({ id: 'zero-events' }); + + writeRunSnapshot(tmpDir, evalRunId, { totalEvents: 0, byKind: {}, byGuard: {} }); + + const result = await generator(packet, makeSourceRefs({ evalRunId }), makeDeps(tmpDir)); + + assert.ok(result.verdictPath.endsWith('zero-events.md')); + assert.ok(result.bundleDir.endsWith('zero-events')); + + // Verify files exist + assert.ok(existsSync(result.verdictPath), 'verdict markdown exists'); + assert.ok(existsSync(join(result.bundleDir, 'snapshot.json')), 'snapshot.json exists'); + assert.ok(existsSync(join(result.bundleDir, 'attribution.json')), 'attribution.json exists'); + assert.ok(existsSync(join(result.bundleDir, 'provenance.json')), 'provenance.json exists'); + + // Check snapshot + const snapshot = JSON.parse(readFileSync(join(result.bundleDir, 'snapshot.json'), 'utf8')); + assert.equal(snapshot.totalEvents, 0); + assert.equal(snapshot.featureId, 'F257'); + assert.equal(snapshot.components[0].confidence, 'no-data'); + + // Check attribution has noFindingRecord + const attr = JSON.parse(readFileSync(join(result.bundleDir, 'attribution.json'), 'utf8')); + assert.ok(attr.noFindingRecord, 'should have noFindingRecord for zero events'); + assert.equal(attr.findings.length, 0); + + // Check provenance has producedBy.runId (KD-17) + const prov = JSON.parse(readFileSync(join(result.bundleDir, 'provenance.json'), 'utf8')); + assert.equal(prov.producedBy.runId, evalRunId); + + // Check verdict markdown + const md = readFileSync(result.verdictPath, 'utf8'); + assert.ok(md.includes('feedback_type: live-verdict')); + assert.ok(md.includes('domain_id: eval:harness-ledger')); + assert.ok(md.includes('keep_observe')); + assert.ok(md.includes('**Events**: 0')); + + rmSync(tmpDir, { recursive: true }); + }); + + test('produces verdict with events from mixed kinds', async () => { + const generator = createHarnessLedgerGeneratorAdapter(); + const tmpDir = makeTmpDir(); + const evalRunId = safeEvalRunId(); + const packet = makePacket({ id: 'mixed-events' }); + + writeRunSnapshot(tmpDir, evalRunId, { + totalEvents: 3, + byKind: { http_rate_limit: 2, route_decision_block: 1 }, + byGuard: { + hold_ball_rate_limit: { count: 2, kinds: ['http_rate_limit'] }, + a2a_block_pingpong: { count: 1, kinds: ['route_decision_block'] }, + }, + }); + + const result = await generator(packet, makeSourceRefs({ evalRunId }), makeDeps(tmpDir)); + + // Check snapshot + const snapshot = JSON.parse(readFileSync(join(result.bundleDir, 'snapshot.json'), 'utf8')); + assert.equal(snapshot.totalEvents, 3); + assert.equal(snapshot.byKind.http_rate_limit, 2); + assert.equal(snapshot.byKind.route_decision_block, 1); + assert.equal(snapshot.byGuard.hold_ball_rate_limit, 2); + assert.equal(snapshot.byGuard.a2a_block_pingpong, 1); + assert.equal(snapshot.components[0].confidence, 'medium'); + + // Check attribution has schema-compliant findings + const attr = JSON.parse(readFileSync(join(result.bundleDir, 'attribution.json'), 'utf8')); + assert.ok(!attr.noFindingRecord, 'should NOT have noFindingRecord when events exist'); + assert.equal(attr.findings.length, 2); // 2 distinct guards + + const holdBallFinding = attr.findings.find((f) => f.id === 'f257-guard-hold_ball_rate_limit'); + assert.ok(holdBallFinding, 'finding for hold_ball_rate_limit exists'); + assert.equal(holdBallFinding.frictionSignal.severity, 'low'); // 2 events < 5 + assert.equal(holdBallFinding.frictionSignal.confidence, 0.7); + assert.equal(holdBallFinding.frictionSignal.type, 'http_rate_limit'); + assert.equal(holdBallFinding.attribution.primaryLayer, 'guard-rejection-log'); + assert.ok(holdBallFinding.attribution.evidence.length >= 1); + assert.equal(holdBallFinding.attribution.evidence[0].anchor, 'guard-rejection-log/http_rate_limit'); + assert.equal(holdBallFinding.proposedAction[0].target, 'hold_ball_rate_limit'); + + const pingpongFinding = attr.findings.find((f) => f.id === 'f257-guard-a2a_block_pingpong'); + assert.ok(pingpongFinding, 'finding for a2a_block_pingpong exists'); + assert.equal(pingpongFinding.frictionSignal.type, 'route_decision_block'); + assert.equal(pingpongFinding.attribution.evidence[0].anchor, 'guard-rejection-log/route_decision_block'); + + // Check verdict markdown + const md = readFileSync(result.verdictPath, 'utf8'); + assert.ok(md.includes('**Events**: 3')); + assert.ok(md.includes('http_rate_limit')); + assert.ok(md.includes('route_decision_block')); + + rmSync(tmpDir, { recursive: true }); + }); + + test('rejects window mismatch between selector and stored snapshot (KD-17)', async () => { + const generator = createHarnessLedgerGeneratorAdapter(); + const tmpDir = makeTmpDir(); + const evalRunId = safeEvalRunId(); + const packet = makePacket({ id: 'window-mismatch' }); + + // Snapshot stored with default window [DEFAULT_WINDOW_START, DEFAULT_WINDOW_END) + writeRunSnapshot(tmpDir, evalRunId, { totalEvents: 0, byKind: {}, byGuard: {} }); + + // Selector claims a DIFFERENT window — KD-17 invariant: decision and artifact must share the same data source + const driftedStart = DEFAULT_WINDOW_START + 1000; + const driftedEnd = DEFAULT_WINDOW_END + 1000; + + await assert.rejects( + () => + generator( + packet, + makeSourceRefs({ windowStartMs: driftedStart, windowEndMs: driftedEnd, evalRunId }), + makeDeps(tmpDir), + ), + (err) => { + assert.ok(err.message.includes('harness_ledger_adapter_window_mismatch')); + assert.ok(err.message.includes('KD-17')); + return true; + }, + ); + + rmSync(tmpDir, { recursive: true }); + }); + + test('rejects evalRunId with invalid format (path traversal defense)', async () => { + const generator = createHarnessLedgerGeneratorAdapter(); + const tmpDir = makeTmpDir(); + const packet = makePacket({ id: 'traversal' }); + + // These are all format-invalid: defense-in-depth rejects them before filesystem access + const maliciousIds = [ + '../../../etc/passwd', + 'hlr-123-GGGGGGGG', // uppercase hex + 'hlr-notanumber-abcdef01', // non-numeric timestamp + 'run-1700000000000-abcdef01', // wrong prefix + 'hlr-1700000000000-abc', // too-short hex + ]; + + for (const badId of maliciousIds) { + await assert.rejects( + () => + generator( + packet, + { + kind: 'prompt-segments', + windowStartMs: DEFAULT_WINDOW_START, + windowEndMs: DEFAULT_WINDOW_END, + evalRunId: badId, + }, + makeDeps(tmpDir), + ), + (err) => { + assert.ok( + err.message.includes('harness_ledger_adapter_invalid_run_id'), + `expected invalid_run_id error for '${badId}', got: ${err.message}`, + ); + return true; + }, + ); + } + + rmSync(tmpDir, { recursive: true }); + }); + + test('provenance contains sha256 of snapshot + producedBy.runId', async () => { + const { createHash } = await import('node:crypto'); + const generator = createHarnessLedgerGeneratorAdapter(); + const tmpDir = makeTmpDir(); + const evalRunId = safeEvalRunId(); + const packet = makePacket({ id: 'prov-check' }); + + writeRunSnapshot(tmpDir, evalRunId); + + const result = await generator(packet, makeSourceRefs({ evalRunId }), makeDeps(tmpDir)); + + const snapshotJson = readFileSync(join(result.bundleDir, 'snapshot.json'), 'utf8'); + const expectedSha = createHash('sha256').update(snapshotJson).digest('hex'); + + const provenance = JSON.parse(readFileSync(join(result.bundleDir, 'provenance.json'), 'utf8')); + assert.equal(provenance.rawInputs[0].sha256, expectedSha); + assert.equal(provenance.generator.name, 'harness-ledger-generator-adapter'); + assert.equal(provenance.producedBy.runId, evalRunId); + + rmSync(tmpDir, { recursive: true }); + }); + + test('verdict markdown uses packet fields when present', async () => { + const generator = createHarnessLedgerGeneratorAdapter(); + const tmpDir = makeTmpDir(); + const evalRunId = safeEvalRunId(); + + writeRunSnapshot(tmpDir, evalRunId, { + totalEvents: 1, + byKind: { http_rate_limit: 1 }, + byGuard: { hold_ball_rate_limit: { count: 1, kinds: ['http_rate_limit'] } }, + }); + + const packet = makePacket({ + id: 'custom-verdict', + verdict: 'regress', + phenomenon: 'Guard rejections spiked after latest deploy', + harnessUnderEval: { featureId: 'F257', componentId: 'guard-rejection-log', name: 'Harness Ledger v2' }, + ownerAsk: { requestedAction: 'Investigate spike in hold_ball rejections' }, + acceptanceReevalPlan: { nextEvalAt: '2026-07-17T00:00:00Z' }, + }); + + const result = await generator(packet, makeSourceRefs({ evalRunId }), makeDeps(tmpDir)); + + const md = readFileSync(result.verdictPath, 'utf8'); + assert.ok(md.includes('`regress`'), 'uses packet verdict'); + assert.ok(md.includes('Guard rejections spiked'), 'uses packet phenomenon'); + assert.ok(md.includes('Harness Ledger v2'), 'uses packet harnessUnderEval'); + assert.ok(md.includes('Investigate spike'), 'uses packet ownerAsk'); + assert.ok(md.includes('2026-07-17'), 'uses packet reevalPlan'); + + rmSync(tmpDir, { recursive: true }); + }); + + test('verdict YAML frontmatter includes all required Eval Hub fields', async () => { + const generator = createHarnessLedgerGeneratorAdapter(); + const tmpDir = makeTmpDir(); + const evalRunId = safeEvalRunId(); + const packet = makePacket({ id: 'frontmatter-check' }); + + writeRunSnapshot(tmpDir, evalRunId); + + const result = await generator(packet, makeSourceRefs({ evalRunId }), makeDeps(tmpDir)); + const md = readFileSync(result.verdictPath, 'utf8'); + + assert.ok(md.includes('feature_ids: [F257]')); + assert.ok(md.includes('doc_kind: harness-feedback')); + assert.ok(md.includes('feedback_type: live-verdict')); + assert.ok(md.includes('domain_id: eval:harness-ledger')); + assert.ok(md.includes('packet_id: frontmatter-check')); + assert.ok(md.includes('source_snapshot:')); + + rmSync(tmpDir, { recursive: true }); + }); + + test('bundle snapshot window matches selector', async () => { + const generator = createHarnessLedgerGeneratorAdapter(); + const tmpDir = makeTmpDir(); + const evalRunId = safeEvalRunId(); + const packet = makePacket({ id: 'window-check' }); + + // Uses default window from helpers — both makeSourceRefs and writeRunSnapshot share DEFAULT_WINDOW_START/END + writeRunSnapshot(tmpDir, evalRunId); + + const result = await generator(packet, makeSourceRefs({ evalRunId }), makeDeps(tmpDir)); + + const snapshot = JSON.parse(readFileSync(join(result.bundleDir, 'snapshot.json'), 'utf8')); + assert.equal(snapshot.window.startMs, DEFAULT_WINDOW_START); + assert.equal(snapshot.window.endMs, DEFAULT_WINDOW_END); + assert.equal(snapshot.window.durationHours, 168); // 7 days × 24h + + rmSync(tmpDir, { recursive: true }); + }); + + // ── Resolver round-trip: bundles pass resolveA2aEvidenceBundle validation ── + + test('zero-events bundle passes resolveA2aEvidenceBundle round-trip', async () => { + const { resolveA2aEvidenceBundle } = await import( + '../dist/infrastructure/harness-eval/a2a/eval-a2a-artifact-resolver.js' + ); + const generator = createHarnessLedgerGeneratorAdapter(); + const tmpDir = makeTmpDir(); + const evalRunId = safeEvalRunId(); + const packet = makePacket({ id: 'roundtrip-zero' }); + + writeRunSnapshot(tmpDir, evalRunId, { totalEvents: 0, byKind: {}, byGuard: {} }); + + const result = await generator(packet, makeSourceRefs({ evalRunId }), makeDeps(tmpDir)); + + const resolved = resolveA2aEvidenceBundle({ verdictId: packet.id, bundleDir: result.bundleDir }); + + assert.equal(resolved.verdictId, packet.id); + assert.ok(resolved.snapshot.featureId === 'F257'); + assert.equal(resolved.attributionReport.findings.length, 0); + assert.ok(resolved.attributionReport.noFindingRecord); + assert.equal(resolved.provenance.generator.name, 'harness-ledger-generator-adapter'); + + rmSync(tmpDir, { recursive: true }); + }); + + test('mixed-events bundle passes resolveA2aEvidenceBundle round-trip', async () => { + const { resolveA2aEvidenceBundle } = await import( + '../dist/infrastructure/harness-eval/a2a/eval-a2a-artifact-resolver.js' + ); + const generator = createHarnessLedgerGeneratorAdapter(); + const tmpDir = makeTmpDir(); + const evalRunId = safeEvalRunId(); + const packet = makePacket({ id: 'roundtrip-mixed' }); + + writeRunSnapshot(tmpDir, evalRunId, { + totalEvents: 3, + byKind: { http_rate_limit: 2, route_decision_block: 1 }, + byGuard: { + hold_ball_rate_limit: { count: 2, kinds: ['http_rate_limit'] }, + a2a_block_pingpong: { count: 1, kinds: ['route_decision_block'] }, + }, + }); + + const result = await generator(packet, makeSourceRefs({ evalRunId }), makeDeps(tmpDir)); + + const resolved = resolveA2aEvidenceBundle({ verdictId: packet.id, bundleDir: result.bundleDir }); + + assert.equal(resolved.verdictId, packet.id); + assert.ok(resolved.snapshot.featureId === 'F257'); + assert.ok(resolved.snapshot.window.durationHours >= 0); + assert.ok(resolved.snapshot.components.length >= 1); + assert.equal(resolved.attributionReport.findings.length, 2); + assert.ok(!resolved.attributionReport.noFindingRecord); + + const finding = resolved.attributionReport.findings[0]; + assert.ok(finding.id.startsWith('f257-guard-')); + assert.ok(['low', 'medium', 'high'].includes(finding.frictionSignal.severity)); + assert.equal(finding.attribution.primaryLayer, 'guard-rejection-log'); + assert.ok(finding.attribution.evidence.length >= 1); + assert.ok(finding.proposedAction.length >= 1); + + rmSync(tmpDir, { recursive: true }); + }); +}); diff --git a/packages/api/test/helpers/incremental-context-helpers.js b/packages/api/test/helpers/incremental-context-helpers.js index 8c4737fb77..839828466f 100644 --- a/packages/api/test/helpers/incremental-context-helpers.js +++ b/packages/api/test/helpers/incremental-context-helpers.js @@ -4,6 +4,11 @@ export function mockMsg(overrides) { threadId: overrides.threadId ?? 'thread-1', userId: overrides.userId ?? 'user-1', catId: overrides.catId ?? null, + provenance: overrides.provenance ?? { + author: overrides.catId ? 'cat' : 'user', + routed: false, + observation: 'original', + }, content: overrides.content ?? 'test message', mentions: overrides.mentions ?? [], timestamp: ts, diff --git a/packages/api/test/hook-override-store.test.js b/packages/api/test/hook-override-store.test.js new file mode 100644 index 0000000000..602fb02ab8 --- /dev/null +++ b/packages/api/test/hook-override-store.test.js @@ -0,0 +1,1621 @@ +/** + * F237 PR3 — HookOverrideStore + HookRegistry override integration tests + * + * P1 fix (PR #22): manifest is resolved internally via manifestLookup, + * not passed by callers — prevents gate bypass via mismatched hookId/manifest. + */ + +import assert from 'node:assert/strict'; +import { beforeEach, describe, test } from 'node:test'; + +// ── FakeRedis with HASH + sorted set support ── + +class FakeRedis { + constructor() { + this.kv = new Map(); + this.hashes = new Map(); // key → Map + this.sorted = new Map(); // key → Map + this.ttls = new Map(); + } + + async set(key, value, ...args) { + this.kv.set(key, value); + if (args[0] === 'EX' && typeof args[1] === 'number') { + this.ttls.set(key, args[1]); + } + return 'OK'; + } + + async get(key) { + return this.kv.get(key) ?? null; + } + + async del(key) { + const existed = this.kv.has(key) ? 1 : 0; + this.kv.delete(key); + return existed; + } + + async hset(key, field, value) { + const h = this.hashes.get(key) ?? new Map(); + h.set(field, value); + this.hashes.set(key, h); + return 1; + } + + async hget(key, field) { + return this.hashes.get(key)?.get(field) ?? null; + } + + async hgetall(key) { + const h = this.hashes.get(key); + if (!h || h.size === 0) return null; + return Object.fromEntries(h.entries()); + } + + async hdel(key, field) { + const h = this.hashes.get(key); + if (!h) return 0; + return h.delete(field) ? 1 : 0; + } + + async zadd(key, score, member) { + const s = this.sorted.get(key) ?? new Map(); + s.set(member, score); + this.sorted.set(key, s); + return 1; + } + + async zremrangebyscore(key, min, max) { + const s = this.sorted.get(key); + if (!s) return 0; + const minN = typeof min === 'number' ? min : 0; + const maxN = typeof max === 'number' ? max : Infinity; + let removed = 0; + for (const [member, score] of [...s.entries()]) { + if (score >= minN && score <= maxN) { + s.delete(member); + removed++; + } + } + return removed; + } + + async zrangebyscore(key, min, max, ...args) { + const s = this.sorted.get(key); + if (!s) return []; + const minN = typeof min === 'number' ? min : 0; + const maxN = max === '+inf' ? Infinity : Number(max); + let entries = [...s.entries()].filter(([, score]) => score >= minN && score <= maxN).sort((a, b) => a[1] - b[1]); + if (args[0] === 'LIMIT') { + const offset = args[1] ?? 0; + const count = args[2] ?? entries.length; + entries = entries.slice(offset, offset + count); + } + return entries.map(([member]) => member); + } + + /** R7: SETNX — set if not exists (atomic in production Redis). */ + async setnx(key, value) { + if (this.kv.has(key)) return 0; + this.kv.set(key, value); + return 1; + } + + /** R7: INCR — atomic increment (returns new value). */ + async incr(key) { + const val = this.kv.get(key); + const num = val ? Number.parseInt(val, 10) : 0; + const next = num + 1; + this.kv.set(key, String(next)); + return next; + } +} + +// ── Test manifest factories ── + +function makeManifest(id, overrides = {}) { + return { + id, + name: `Test ${id}`, + stage: 'session-init', + order: 100, + version: 1, + enabled: true, + template: `${id.toLowerCase()}.md`, + inputs: [], + disableable: true, + safetyTier: 'editable', + transparencyTier: 'visible-by-default', + governanceTier: 'immutable', + ...overrides, + }; +} + +/** + * Build a manifestLookup function from a set of manifests. + * This mirrors the production pattern where the store resolves manifests + * from the registry by hookId — callers never pass manifests directly. + */ +function buildLookup(...manifests) { + const map = new Map(manifests.map((m) => [m.id, m])); + return (hookId) => map.get(hookId); +} + +// ── Tests ── + +describe('HookOverrideStore', () => { + /** @type {import('../dist/domains/prompt-hooks/HookOverrideStore.js').HookOverrideStore} */ + let store; + let redis; + + // Default manifests for most tests + const S1 = makeManifest('S1'); + const S2 = makeManifest('S2', { disableable: true }); + const D5 = makeManifest('D5', { safetyTier: 'editable' }); + const D8 = makeManifest('D8', { safetyTier: 'limited-edit' }); + + beforeEach(async () => { + redis = new FakeRedis(); + const mod = await import('../dist/domains/prompt-hooks/HookOverrideStore.js'); + store = new mod.HookOverrideStore(redis, buildLookup(S1, S2, D5, D8)); + }); + + describe('enable/disable', () => { + test('enable writes override and records event', async () => { + await store.enable('S1', 'opus'); + + const override = await store.getOverride('S1'); + assert.equal(override.hookId, 'S1'); + assert.equal(override.enabled, true); + assert.equal(override.source, 'operator'); + assert.equal(override.updatedBy, 'opus'); + + const events = await store.listEvents(); + assert.equal(events.length, 1); + assert.equal(events[0].action, 'enable'); + assert.equal(events[0].hookId, 'S1'); + }); + + test('disable writes override for disableable hook', async () => { + await store.disable('S2', 'codex'); + + const override = await store.getOverride('S2'); + assert.equal(override.enabled, false); + assert.equal(override.updatedBy, 'codex'); + }); + + test('disable rejects non-disableable hook', async () => { + // Build store with S1 as non-disableable + const mod = await import('../dist/domains/prompt-hooks/HookOverrideStore.js'); + const s1NotDisableable = makeManifest('S1', { disableable: false }); + const restrictedStore = new mod.HookOverrideStore(redis, buildLookup(s1NotDisableable)); + + await assert.rejects( + () => restrictedStore.disable('S1', 'opus'), + (err) => { + assert.equal(err.name, 'OverrideGateError'); + assert.equal(err.gate, 'disableable'); + return true; + }, + ); + }); + }); + + describe('content override', () => { + test('setContentOverride stores content and increments version', async () => { + await store.setContentOverride('D5', 'new content v1', 'opus'); + + const o1 = await store.getOverride('D5'); + assert.equal(o1.contentOverride, 'new content v1'); + assert.equal(o1.contentVersion, 1); + + await store.setContentOverride('D5', 'new content v2', 'opus'); + const o2 = await store.getOverride('D5'); + assert.equal(o2.contentOverride, 'new content v2'); + assert.equal(o2.contentVersion, 2); + }); + + test('setContentOverride rejects readonly safetyTier', async () => { + // Build store with S1 as readonly + const mod = await import('../dist/domains/prompt-hooks/HookOverrideStore.js'); + const s1Readonly = makeManifest('S1', { safetyTier: 'readonly' }); + const restrictedStore = new mod.HookOverrideStore(redis, buildLookup(s1Readonly)); + + await assert.rejects( + () => restrictedStore.setContentOverride('S1', 'hack', 'opus'), + (err) => err.gate === 'safetyTier' && err.manifestValue === 'readonly', + ); + }); + + test('setContentOverride rejects limited-edit with auto-eval source', async () => { + await assert.rejects( + () => store.setContentOverride('D8', 'new', 'system', { source: 'auto-eval' }), + (err) => err.gate === 'safetyTier' && err.manifestValue === 'limited-edit', + ); + }); + + test('setContentOverride allows limited-edit with operator source', async () => { + await store.setContentOverride('D8', 'fixed text', 'operator', { source: 'operator' }); + const o = await store.getOverride('D8'); + assert.equal(o.contentOverride, 'fixed text'); + }); + + test('clearContentOverride removes content but keeps other override state', async () => { + await store.disable('D5', 'opus'); + await store.setContentOverride('D5', 'override text', 'opus'); + await store.clearContentOverride('D5', 'opus'); + + const o = await store.getOverride('D5'); + assert.equal(o.enabled, false); + assert.equal(o.contentOverride, undefined); + assert.equal(o.contentVersion, undefined); + }); + }); + + describe('rollback', () => { + test('rollback removes all override state for a hook', async () => { + await store.disable('D5', 'opus'); + await store.setContentOverride('D5', 'override', 'opus'); + await store.rollback('D5', 'opus'); + + const o = await store.getOverride('D5'); + assert.equal(o, null); + }); + + test('rollback records event', async () => { + await store.rollback('D5', 'opus'); + const events = await store.listEvents(); + const rollbackEvent = events.find((e) => e.action === 'rollback'); + assert.ok(rollbackEvent); + assert.equal(rollbackEvent.hookId, 'D5'); + }); + }); + + describe('listOverrides + loadSnapshot', () => { + test('listOverrides returns all overrides for workspace', async () => { + await store.enable('S1', 'opus'); + await store.disable('S2', 'codex'); + + const list = await store.listOverrides(); + assert.equal(list.length, 2); + const ids = list.map((o) => o.hookId).sort(); + assert.deepEqual(ids, ['S1', 'S2']); + }); + + test('loadSnapshot returns ReadonlyMap keyed by hookId', async () => { + await store.disable('D5', 'opus'); + const snapshot = await store.loadSnapshot(); + assert.equal(snapshot.size, 1); + assert.equal(snapshot.get('D5').enabled, false); + }); + }); + + describe('per-workspace isolation', () => { + test('overrides in different workspaces are independent', async () => { + await store.enable('S1', 'opus', { workspaceId: 'ws-a' }); + await store.disable('S1', 'opus', { workspaceId: 'ws-b' }); + + const oA = await store.getOverride('S1', 'ws-a'); + const oB = await store.getOverride('S1', 'ws-b'); + assert.equal(oA.enabled, true); + assert.equal(oB.enabled, false); + }); + }); + + describe('event stream', () => { + test('events are recorded with correct fields', async () => { + await store.disable('D5', 'opus'); + await store.enable('D5', 'codex'); + + const events = await store.listEvents(); + assert.equal(events.length, 2); + assert.equal(events[0].action, 'disable'); + assert.equal(events[0].actorId, 'opus'); + assert.equal(events[1].action, 'enable'); + assert.equal(events[1].actorId, 'codex'); + }); + }); + + describe('safety gate — mismatched hookId bypass prevention (P1 regression)', () => { + test('disable rejects unknown hookId (fail-closed)', async () => { + await assert.rejects( + () => store.disable('UNKNOWN', 'opus'), + (err) => { + assert.equal(err.name, 'OverrideGateError'); + assert.equal(err.gate, 'unknown-hook'); + assert.equal(err.hookId, 'UNKNOWN'); + return true; + }, + ); + }); + + test('enable rejects unknown hookId (fail-closed)', async () => { + await assert.rejects( + () => store.enable('UNKNOWN', 'opus'), + (err) => { + assert.equal(err.name, 'OverrideGateError'); + assert.equal(err.gate, 'unknown-hook'); + return true; + }, + ); + }); + + test('setContentOverride rejects unknown hookId (fail-closed)', async () => { + await assert.rejects( + () => store.setContentOverride('UNKNOWN', 'hacked', 'opus'), + (err) => { + assert.equal(err.name, 'OverrideGateError'); + assert.equal(err.gate, 'unknown-hook'); + return true; + }, + ); + }); + + test('rollback rejects unknown hookId (fail-closed) — no audit event written (terra P2)', async () => { + await assert.rejects( + () => store.rollback('UNKNOWN', 'opus'), + (err) => { + assert.equal(err.name, 'OverrideGateError'); + assert.equal(err.gate, 'unknown-hook'); + assert.equal(err.hookId, 'UNKNOWN'); + return true; + }, + ); + const events = await store.listEvents(); + assert.equal(events.length, 0, 'permanent audit stream must stay clean for unknown hooks'); + }); + + test('clearContentOverride rejects unknown hookId (fail-closed) — sibling of rollback gate', async () => { + await assert.rejects( + () => store.clearContentOverride('UNKNOWN', 'opus'), + (err) => { + assert.equal(err.name, 'OverrideGateError'); + assert.equal(err.gate, 'unknown-hook'); + return true; + }, + ); + const events = await store.listEvents(); + assert.equal(events.length, 0); + }); + + test('rollback on orphaned override (hook removed from registry) fails closed — override survives, no event', async () => { + // Phase 1: hook exists → operator disables it (override + event written) + await store.disable('S2', 'opus', { reason: 'pre-upgrade disable' }); + // Phase 2: package upgrade removes the hook from the registry + const mod = await import('../dist/domains/prompt-hooks/HookOverrideStore.js'); + const orphanStore = new mod.HookOverrideStore(redis, buildLookup(S1)); // S2 gone + await assert.rejects( + () => orphanStore.rollback('S2', 'opus'), + (err) => err.gate === 'unknown-hook', + ); + // Deliberate fail-closed tradeoff (terra review): orphaned overrides are NOT + // clearable via this operator path — needs a dedicated migration channel, + // not an arbitrary-string write into the permanent audit stream. + const survivor = await orphanStore.getOverride('S2'); + assert.notEqual(survivor, null, 'orphaned override left untouched'); + const events = await orphanStore.listEvents(); + assert.equal(events.length, 1, 'only the original disable event exists — no rollback event'); + }); + + test('cannot disable non-disableable S1 by passing D5 manifest identity — gate uses internal lookup', async () => { + // This is the exact codex P1 repro scenario: + // Before the fix, caller could pass D5's manifest (disableable:true) with hookId='S1' + // to bypass S1's disableable:false gate. Now the store resolves manifest internally. + const mod = await import('../dist/domains/prompt-hooks/HookOverrideStore.js'); + const s1Protected = makeManifest('S1', { disableable: false, safetyTier: 'readonly' }); + const d5Editable = makeManifest('D5', { disableable: true, safetyTier: 'editable' }); + const protectedStore = new mod.HookOverrideStore(redis, buildLookup(s1Protected, d5Editable)); + + // Attempt to disable S1 — gate must check S1's own manifest (disableable:false), not any other + await assert.rejects( + () => protectedStore.disable('S1', 'opus'), + (err) => { + assert.equal(err.name, 'OverrideGateError'); + assert.equal(err.hookId, 'S1'); + assert.equal(err.gate, 'disableable'); + return true; + }, + ); + + // Attempt to content-override S1 — gate must check S1's own manifest (readonly), not any other + await assert.rejects( + () => protectedStore.setContentOverride('S1', 'injected', 'opus'), + (err) => { + assert.equal(err.name, 'OverrideGateError'); + assert.equal(err.hookId, 'S1'); + assert.equal(err.gate, 'safetyTier'); + assert.equal(err.manifestValue, 'readonly'); + return true; + }, + ); + + // D5 should still work (its own manifest is permissive) + await protectedStore.disable('D5', 'opus'); + await protectedStore.setContentOverride('D5', 'legitimate override', 'opus'); + const d5Override = await protectedStore.getOverride('D5'); + assert.equal(d5Override.enabled, false); + assert.equal(d5Override.contentOverride, 'legitimate override'); + + // S1 must remain untouched + const s1Override = await protectedStore.getOverride('S1'); + assert.equal(s1Override, null); + }); + }); +}); + +describe('HookRegistry override integration', () => { + /** @type {import('../dist/domains/prompt-hooks/HookRegistry.js').HookRegistry} */ + let HookRegistry; + + beforeEach(async () => { + const mod = await import('../dist/domains/prompt-hooks/HookRegistry.js'); + HookRegistry = mod.HookRegistry; + }); + + test('isEnabled returns manifest baseline when no overrides', async () => { + const { mkdirSync, writeFileSync, rmSync } = await import('node:fs'); + const { join } = await import('node:path'); + const dir = join(import.meta.dirname, '__fixtures__', 'override-test-1'); + rmSync(dir, { recursive: true, force: true }); + mkdirSync(join(dir, 's1'), { recursive: true }); + writeFileSync( + join(dir, 's1', 'hook.yaml'), + [ + 'id: S1', + 'name: Test S1', + 'stage: session-init', + 'order: 100', + 'version: 3', + 'enabled: true', + 'template: s1.md', + 'inputs: []', + 'disableable: true', + 'safetyTier: editable', + 'transparencyTier: visible-by-default', + 'governanceTier: immutable', + ].join('\n'), + ); + writeFileSync(join(dir, 's1', 's1.md'), ''); + + const registry = new HookRegistry(dir); + registry.scan(); + assert.equal(registry.isEnabled('S1'), true); + assert.equal(registry.getActiveVersion('S1'), 3); + assert.equal(registry.getDisabledBySource('S1'), 'manifest'); + assert.equal(registry.getContentOverride('S1'), undefined); + + rmSync(dir, { recursive: true, force: true }); + }); + + test('override snapshot overrides manifest baseline', async () => { + const { mkdirSync, writeFileSync, rmSync } = await import('node:fs'); + const { join } = await import('node:path'); + const dir = join(import.meta.dirname, '__fixtures__', 'override-test-2'); + rmSync(dir, { recursive: true, force: true }); + mkdirSync(join(dir, 's1'), { recursive: true }); + writeFileSync( + join(dir, 's1', 'hook.yaml'), + [ + 'id: S1', + 'name: Test S1', + 'stage: session-init', + 'order: 100', + 'version: 1', + 'enabled: true', + 'template: s1.md', + 'inputs: []', + 'disableable: true', + 'safetyTier: editable', + 'transparencyTier: visible-by-default', + 'governanceTier: immutable', + ].join('\n'), + ); + writeFileSync(join(dir, 's1', 's1.md'), ''); + + const registry = new HookRegistry(dir); + registry.scan(); + + // Set override: disable S1 via operator + const snapshot = new Map(); + snapshot.set('S1', { + hookId: 'S1', + enabled: false, + contentOverride: 'overridden content', + contentVersion: 5, + source: 'operator', + updatedAt: Date.now(), + updatedBy: 'opus', + }); + registry.setOverrideSnapshot(snapshot); + + assert.equal(registry.isEnabled('S1'), false); + assert.equal(registry.getActiveVersion('S1'), 5); + assert.equal(registry.getDisabledBySource('S1'), 'operator'); + assert.equal(registry.getContentOverride('S1'), 'overridden content'); + + // Clear overrides → back to manifest + registry.clearOverrideSnapshot(); + assert.equal(registry.isEnabled('S1'), true); + assert.equal(registry.getActiveVersion('S1'), 1); + + rmSync(dir, { recursive: true, force: true }); + }); + + test('auto-eval source maps to correct disabledBy', async () => { + const { mkdirSync, writeFileSync, rmSync } = await import('node:fs'); + const { join } = await import('node:path'); + const dir = join(import.meta.dirname, '__fixtures__', 'override-test-3'); + rmSync(dir, { recursive: true, force: true }); + mkdirSync(join(dir, 'd5'), { recursive: true }); + writeFileSync( + join(dir, 'd5', 'hook.yaml'), + [ + 'id: D5', + 'name: Test D5', + 'stage: per-turn', + 'order: 500', + 'version: 1', + 'enabled: true', + 'template: d5.md', + 'inputs: []', + 'disableable: true', + 'safetyTier: editable', + 'transparencyTier: visible-by-default', + 'governanceTier: auto-evolve', + ].join('\n'), + ); + writeFileSync(join(dir, 'd5', 'd5.md'), ''); + + const registry = new HookRegistry(dir); + registry.scan(); + + registry.setOverrideSnapshot( + new Map([ + [ + 'D5', + { + hookId: 'D5', + enabled: false, + source: 'auto-eval', + updatedAt: Date.now(), + updatedBy: 'system', + }, + ], + ]), + ); + + assert.equal(registry.getDisabledBySource('D5'), 'auto-eval'); + + rmSync(dir, { recursive: true, force: true }); + }); +}); + +describe('End-to-end: HookOverrideStore → HookRegistry → HookPipeline', () => { + test('disable override suppresses hook in pipeline output', async () => { + const { mkdirSync, writeFileSync, rmSync } = await import('node:fs'); + const { join } = await import('node:path'); + + // Set up two hooks: H1 (will be disabled via override) and H2 (baseline) + const dir = join(import.meta.dirname, '__fixtures__', 'e2e-override-test'); + rmSync(dir, { recursive: true, force: true }); + for (const id of ['h1', 'h2']) { + mkdirSync(join(dir, id), { recursive: true }); + writeFileSync( + join(dir, id, 'hook.yaml'), + [ + `id: ${id.toUpperCase()}`, + `name: Test ${id.toUpperCase()}`, + 'stage: session-init', + `order: ${id === 'h1' ? 100 : 200}`, + 'version: 1', + 'enabled: true', + `template: ${id}.md`, + 'inputs: []', + 'disableable: true', + 'safetyTier: editable', + 'transparencyTier: visible-by-default', + 'governanceTier: immutable', + ].join('\n'), + ); + writeFileSync(join(dir, id, `${id}.md`), `Content from ${id.toUpperCase()}`); + } + + const { HookRegistry } = await import('../dist/domains/prompt-hooks/HookRegistry.js'); + const { HookPipeline } = await import('../dist/domains/prompt-hooks/HookPipeline.js'); + const { HookOverrideStore } = await import('../dist/domains/prompt-hooks/HookOverrideStore.js'); + + const redis = new FakeRedis(); + const registry = new HookRegistry(dir); + registry.scan(); + + // Build store with registry-backed manifest lookup + const manifestLookup = (hookId) => registry.getHook(hookId)?.manifest; + const store = new HookOverrideStore(redis, manifestLookup); + + // Baseline: both hooks fire + const pipeline1 = new HookPipeline(registry, new Map(), (id) => `Content from ${id}`); + const input = { catId: 'opus' }; + const baseline = pipeline1.executeStage('session-init', input); + assert.equal(baseline.patches.length, 2, 'Both hooks fire at baseline'); + assert.equal(baseline.events.filter((e) => e.status === 'fired').length, 2); + + // Disable H1 via override store → load snapshot → inject into registry + await store.disable('H1', 'opus', { reason: 'e2e test' }); + const snapshot = await store.loadSnapshot(); + registry.setOverrideSnapshot(snapshot); + + // After override: only H2 fires, H1 is disabled + const pipeline2 = new HookPipeline(registry, new Map(), (id) => `Content from ${id}`); + const overridden = pipeline2.executeStage('session-init', input); + assert.equal(overridden.patches.length, 1, 'Only H2 fires after H1 disabled'); + assert.equal(overridden.patches[0].hookId, 'H2'); + const disabledEvent = overridden.events.find((e) => e.hookId === 'H1'); + assert.equal(disabledEvent.status, 'disabled'); + assert.equal(disabledEvent.disabledBy, 'operator'); + + // Rollback H1 → clears override → both fire again + await store.rollback('H1', 'opus'); + const snapshot2 = await store.loadSnapshot(); + registry.setOverrideSnapshot(snapshot2); + + const pipeline3 = new HookPipeline(registry, new Map(), (id) => `Content from ${id}`); + const restored = pipeline3.executeStage('session-init', input); + assert.equal(restored.patches.length, 2, 'Both hooks fire after rollback'); + + // Verify event stream records the full lifecycle + const events = await store.listEvents(); + assert.equal(events.length, 2); // disable + rollback + assert.equal(events[0].action, 'disable'); + assert.equal(events[0].reason, 'e2e test'); + assert.equal(events[1].action, 'rollback'); + + rmSync(dir, { recursive: true, force: true }); + }); + + test('content override changes pipeline output', async () => { + const { mkdirSync, writeFileSync, rmSync } = await import('node:fs'); + const { join } = await import('node:path'); + + const dir = join(import.meta.dirname, '__fixtures__', 'e2e-content-test'); + rmSync(dir, { recursive: true, force: true }); + mkdirSync(join(dir, 'h1'), { recursive: true }); + writeFileSync( + join(dir, 'h1', 'hook.yaml'), + [ + 'id: H1', + 'name: Test H1', + 'stage: session-init', + 'order: 100', + 'version: 1', + 'enabled: true', + 'template: h1.md', + 'inputs: []', + 'disableable: true', + 'safetyTier: editable', + 'transparencyTier: visible-by-default', + 'governanceTier: immutable', + ].join('\n'), + ); + writeFileSync(join(dir, 'h1', 'h1.md'), 'Original baseline content'); + + const { HookRegistry } = await import('../dist/domains/prompt-hooks/HookRegistry.js'); + const { HookPipeline } = await import('../dist/domains/prompt-hooks/HookPipeline.js'); + const { HookOverrideStore } = await import('../dist/domains/prompt-hooks/HookOverrideStore.js'); + + const redis = new FakeRedis(); + const registry = new HookRegistry(dir); + registry.scan(); + + // Build store with registry-backed manifest lookup + const manifestLookup = (hookId) => registry.getHook(hookId)?.manifest; + const store = new HookOverrideStore(redis, manifestLookup); + + // Set content override + await store.setContentOverride('H1', 'Overridden by operator', 'opus'); + const snapshot = await store.loadSnapshot(); + registry.setOverrideSnapshot(snapshot); + + // Pipeline should use overridden content + const pipeline = new HookPipeline(registry, new Map(), (id) => `Rendered ${id}`); + const result = pipeline.executeStage('session-init', { catId: 'opus' }); + assert.equal(result.patches.length, 1); + assert.equal(result.patches[0].content, 'Overridden by operator'); + + // R7: Version in trace is now activeEpochVersion (stable monotonic ID), + // not contentVersion (mutable edit counter). First override = manifest(1)+1 = 2. + const firedEvent = result.events.find((e) => e.status === 'fired'); + assert.equal(firedEvent.version, 2); // activeEpochVersion = 2 + + rmSync(dir, { recursive: true, force: true }); + }); +}); + +// ── sol review P1-1: stale overrides must not survive manifest tightening ── + +describe('Manifest tightening — stale override reconciliation (sol P1-1)', () => { + test('loadSnapshot strips disable-override when manifest tightens to non-disableable', async () => { + const redis = new FakeRedis(); + const { HookOverrideStore } = await import('../dist/domains/prompt-hooks/HookOverrideStore.js'); + + // Phase 1: hook is disableable → operator disables it + const v1Manifest = makeManifest('S1', { disableable: true, safetyTier: 'editable' }); + const store1 = new HookOverrideStore(redis, buildLookup(v1Manifest)); + await store1.disable('S1', 'opus', { reason: 'test disable' }); + + // Verify the override was written + const snapshot1 = await store1.loadSnapshot(); + assert.equal(snapshot1.get('S1')?.enabled, false, 'Override was written with enabled:false'); + + // Phase 2: package upgrade tightens S1 to non-disableable → new store with new manifest + const v2Manifest = makeManifest('S1', { disableable: false, safetyTier: 'editable' }); + const store2 = new HookOverrideStore(redis, buildLookup(v2Manifest)); + + // loadSnapshot must reconcile: strip the stale enabled:false + const snapshot2 = await store2.loadSnapshot(); + const override = snapshot2.get('S1'); + assert.notEqual(override, null, 'Override entry still exists'); + assert.equal(override?.enabled, undefined, 'enabled:false stripped — manifest no longer allows disabling'); + }); + + test('loadSnapshot strips contentOverride when manifest tightens to readonly', async () => { + const redis = new FakeRedis(); + const { HookOverrideStore } = await import('../dist/domains/prompt-hooks/HookOverrideStore.js'); + + // Phase 1: hook is editable → operator sets content override + const v1Manifest = makeManifest('D5', { disableable: true, safetyTier: 'editable' }); + const store1 = new HookOverrideStore(redis, buildLookup(v1Manifest)); + await store1.setContentOverride('D5', 'custom content', 'opus'); + + const snapshot1 = await store1.loadSnapshot(); + assert.equal(snapshot1.get('D5')?.contentOverride, 'custom content'); + assert.equal(snapshot1.get('D5')?.contentVersion, 1); + + // Phase 2: package upgrade tightens D5 to readonly + const v2Manifest = makeManifest('D5', { disableable: true, safetyTier: 'readonly' }); + const store2 = new HookOverrideStore(redis, buildLookup(v2Manifest)); + + const snapshot2 = await store2.loadSnapshot(); + const override = snapshot2.get('D5'); + assert.notEqual(override, null, 'Override entry still exists'); + assert.equal(override?.contentOverride, undefined, 'contentOverride stripped — manifest is now readonly'); + assert.equal(override?.contentVersion, undefined, 'contentVersion also stripped'); + }); + + test('loadSnapshot drops overrides for hooks removed from registry', async () => { + const redis = new FakeRedis(); + const { HookOverrideStore } = await import('../dist/domains/prompt-hooks/HookOverrideStore.js'); + + // Phase 1: hook exists → operator disables it + const v1Manifest = makeManifest('REMOVED', { disableable: true }); + const store1 = new HookOverrideStore(redis, buildLookup(v1Manifest)); + await store1.disable('REMOVED', 'opus'); + + // Phase 2: hook removed from registry (manifest lookup returns undefined) + const store2 = new HookOverrideStore(redis, () => undefined); + const snapshot = await store2.loadSnapshot(); + assert.equal(snapshot.get('REMOVED'), undefined, 'Orphaned override is dropped from snapshot'); + }); +}); + +describe('HookRegistry defense-in-depth against stale overrides (sol P1-1)', () => { + test('isEnabled ignores disable-override when manifest is non-disableable', async () => { + const { mkdirSync, writeFileSync, rmSync } = await import('node:fs'); + const { join } = await import('node:path'); + const { HookRegistry } = await import('../dist/domains/prompt-hooks/HookRegistry.js'); + + const dir = join(import.meta.dirname, '__fixtures__', 'sol-p1-1-disable'); + rmSync(dir, { recursive: true, force: true }); + mkdirSync(join(dir, 's1'), { recursive: true }); + writeFileSync( + join(dir, 's1', 'hook.yaml'), + [ + 'id: S1', + 'name: Test S1', + 'stage: session-init', + 'order: 100', + 'version: 1', + 'enabled: true', + 'template: s1.md', + 'inputs: []', + 'disableable: false', + 'safetyTier: readonly', + 'transparencyTier: visible-by-default', + 'governanceTier: immutable', + ].join('\n'), + ); + writeFileSync(join(dir, 's1', 's1.md'), ''); + + const registry = new HookRegistry(dir); + registry.scan(); + + // Inject a stale override that claims to disable S1 + const staleSnapshot = new Map(); + staleSnapshot.set('S1', { + hookId: 'S1', + enabled: false, + source: 'operator', + updatedAt: Date.now(), + updatedBy: 'past-opus', + }); + registry.setOverrideSnapshot(staleSnapshot); + + // Defense-in-depth: isEnabled must respect current manifest, not stale override + assert.equal(registry.isEnabled('S1'), true, 'S1 stays enabled — manifest says non-disableable'); + assert.equal(registry.getDisabledBySource('S1'), 'manifest', 'disabledBy reports manifest, not stale override'); + + rmSync(dir, { recursive: true, force: true }); + }); + + test('getContentOverride ignores stale content when manifest is readonly', async () => { + const { mkdirSync, writeFileSync, rmSync } = await import('node:fs'); + const { join } = await import('node:path'); + const { HookRegistry } = await import('../dist/domains/prompt-hooks/HookRegistry.js'); + + const dir = join(import.meta.dirname, '__fixtures__', 'sol-p1-1-content'); + rmSync(dir, { recursive: true, force: true }); + mkdirSync(join(dir, 's1'), { recursive: true }); + writeFileSync( + join(dir, 's1', 'hook.yaml'), + [ + 'id: S1', + 'name: Test S1', + 'stage: session-init', + 'order: 100', + 'version: 1', + 'enabled: true', + 'template: s1.md', + 'inputs: []', + 'disableable: false', + 'safetyTier: readonly', + 'transparencyTier: visible-by-default', + 'governanceTier: immutable', + ].join('\n'), + ); + writeFileSync(join(dir, 's1', 's1.md'), ''); + + const registry = new HookRegistry(dir); + registry.scan(); + + // Inject a stale override with content on a now-readonly hook + const staleSnapshot = new Map(); + staleSnapshot.set('S1', { + hookId: 'S1', + contentOverride: 'injected content from before tightening', + contentVersion: 3, + source: 'operator', + updatedAt: Date.now(), + updatedBy: 'past-opus', + }); + registry.setOverrideSnapshot(staleSnapshot); + + assert.equal(registry.getContentOverride('S1'), undefined, 'Content override ignored — manifest is readonly'); + + rmSync(dir, { recursive: true, force: true }); + }); +}); + +describe('Audit event TTL=0 (sol P1-2)', () => { + test('events persist without TTL', async () => { + const redis = new FakeRedis(); + const { HookOverrideStore } = await import('../dist/domains/prompt-hooks/HookOverrideStore.js'); + + const store = new HookOverrideStore(redis, buildLookup(makeManifest('S1'))); + await store.enable('S1', 'opus', { reason: 'audit test' }); + + // Find the event key and verify no TTL was set + const eventKeys = [...redis.kv.keys()].filter((k) => k.startsWith('hook-override-event:')); + assert.equal(eventKeys.length, 1, 'One event key was written'); + + const ttl = redis.ttls.get(eventKeys[0]); + assert.equal(ttl, undefined, 'No TTL set on event key — permanent storage per Iron Law 5'); + }); +}); + +// ── Sol round 2: field-level provenance + limited-edit reconciliation ── + +describe('Field-level provenance (sol round 2 — shared source corruption)', () => { + test('enable() sets enabledSource independently of contentSource', async () => { + const redis = new FakeRedis(); + const { HookOverrideStore } = await import('../dist/domains/prompt-hooks/HookOverrideStore.js'); + + const manifest = makeManifest('D5', { + disableable: true, + safetyTier: 'editable', + }); + const store = new HookOverrideStore(redis, buildLookup(manifest)); + + // auto-eval sets content, then operator enables — source should NOT corrupt contentSource + await store.setContentOverride('D5', 'eval content', 'eval-bot', { source: 'auto-eval' }); + await store.enable('D5', 'human', { source: 'operator' }); + + const override = await store.getOverride('D5'); + assert.equal(override?.contentSource, 'auto-eval', 'contentSource preserved from setContentOverride'); + assert.equal(override?.enabledSource, 'operator', 'enabledSource set by enable()'); + assert.equal(override?.source, 'operator', 'source reflects last operation (enable)'); + }); + + test('disable() sets enabledSource independently of contentSource', async () => { + const redis = new FakeRedis(); + const { HookOverrideStore } = await import('../dist/domains/prompt-hooks/HookOverrideStore.js'); + + const manifest = makeManifest('D5', { + disableable: true, + safetyTier: 'editable', + }); + const store = new HookOverrideStore(redis, buildLookup(manifest)); + + // operator sets content, then auto-eval disables + await store.setContentOverride('D5', 'custom content', 'human', { source: 'operator' }); + await store.disable('D5', 'eval-bot', { source: 'auto-eval' }); + + const override = await store.getOverride('D5'); + assert.equal(override?.contentSource, 'operator', 'contentSource preserved from setContentOverride'); + assert.equal(override?.enabledSource, 'auto-eval', 'enabledSource set by disable()'); + }); + + test('clearContentOverride strips contentSource', async () => { + const redis = new FakeRedis(); + const { HookOverrideStore } = await import('../dist/domains/prompt-hooks/HookOverrideStore.js'); + + const manifest = makeManifest('D5', { + disableable: true, + safetyTier: 'editable', + }); + const store = new HookOverrideStore(redis, buildLookup(manifest)); + + await store.setContentOverride('D5', 'content', 'human', { source: 'operator' }); + await store.clearContentOverride('D5', 'human'); + + const override = await store.getOverride('D5'); + assert.equal(override?.contentOverride, undefined, 'contentOverride cleared'); + assert.equal(override?.contentSource, undefined, 'contentSource cleared'); + }); +}); + +describe('Manifest tightening — editable → limited-edit reconciliation (sol round 2)', () => { + test('loadSnapshot strips auto-eval content when manifest tightens to limited-edit', async () => { + const redis = new FakeRedis(); + const { HookOverrideStore } = await import('../dist/domains/prompt-hooks/HookOverrideStore.js'); + + // Phase 1: hook is editable → auto-eval sets content + const v1Manifest = makeManifest('D5', { + disableable: true, + safetyTier: 'editable', + }); + const store1 = new HookOverrideStore(redis, buildLookup(v1Manifest)); + await store1.setContentOverride('D5', 'auto-eval content', 'eval-bot', { source: 'auto-eval' }); + + const snapshot1 = await store1.loadSnapshot(); + assert.equal(snapshot1.get('D5')?.contentOverride, 'auto-eval content', 'Content set while editable'); + + // Phase 2: manifest tightened to limited-edit → auto-eval content must be stripped + const v2Manifest = makeManifest('D5', { + disableable: true, + safetyTier: 'limited-edit', + }); + const store2 = new HookOverrideStore(redis, buildLookup(v2Manifest)); + + const snapshot2 = await store2.loadSnapshot(); + const override = snapshot2.get('D5'); + assert.notEqual(override, null, 'Override entry still exists'); + assert.equal( + override?.contentOverride, + undefined, + 'auto-eval content stripped — limited-edit only allows operator', + ); + }); + + test('loadSnapshot preserves operator content when manifest tightens to limited-edit', async () => { + const redis = new FakeRedis(); + const { HookOverrideStore } = await import('../dist/domains/prompt-hooks/HookOverrideStore.js'); + + // Phase 1: hook is editable → operator sets content + const v1Manifest = makeManifest('D5', { + disableable: true, + safetyTier: 'editable', + }); + const store1 = new HookOverrideStore(redis, buildLookup(v1Manifest)); + await store1.setContentOverride('D5', 'operator content', 'human', { source: 'operator' }); + + // Phase 2: manifest tightened to limited-edit → operator content survives + const v2Manifest = makeManifest('D5', { + disableable: true, + safetyTier: 'limited-edit', + }); + const store2 = new HookOverrideStore(redis, buildLookup(v2Manifest)); + + const snapshot2 = await store2.loadSnapshot(); + const override = snapshot2.get('D5'); + assert.equal( + override?.contentOverride, + 'operator content', + 'operator content preserved — limited-edit allows operator', + ); + }); + + test('loadSnapshot strips content when enable() corrupted source but contentSource is auto-eval', async () => { + const redis = new FakeRedis(); + const { HookOverrideStore } = await import('../dist/domains/prompt-hooks/HookOverrideStore.js'); + + // Phase 1: auto-eval sets content, then operator enables (corrupting shared source) + const v1Manifest = makeManifest('D5', { + disableable: true, + safetyTier: 'editable', + }); + const store1 = new HookOverrideStore(redis, buildLookup(v1Manifest)); + await store1.setContentOverride('D5', 'eval content', 'eval-bot', { source: 'auto-eval' }); + await store1.enable('D5', 'human', { source: 'operator' }); + + // Verify: shared source says 'operator' but contentSource says 'auto-eval' + const raw = await store1.getOverride('D5'); + assert.equal(raw?.source, 'operator', 'Shared source corrupted by enable()'); + assert.equal(raw?.contentSource, 'auto-eval', 'contentSource preserves true provenance'); + + // Phase 2: manifest tightened to limited-edit → must use contentSource, NOT source + const v2Manifest = makeManifest('D5', { + disableable: true, + safetyTier: 'limited-edit', + }); + const store2 = new HookOverrideStore(redis, buildLookup(v2Manifest)); + + const snapshot = await store2.loadSnapshot(); + const override = snapshot.get('D5'); + assert.equal( + override?.contentOverride, + undefined, + 'Content stripped despite source=operator — reconciliation uses contentSource', + ); + }); +}); + +describe('HookRegistry defense-in-depth — limited-edit + provenance (sol round 2)', () => { + test('getContentOverride ignores auto-eval content on limited-edit hook', async () => { + const { mkdirSync, writeFileSync, rmSync } = await import('node:fs'); + const { join } = await import('node:path'); + const { HookRegistry } = await import('../dist/domains/prompt-hooks/HookRegistry.js'); + + const dir = join(import.meta.dirname, '__fixtures__', 'sol-r2-limited'); + rmSync(dir, { recursive: true, force: true }); + mkdirSync(join(dir, 'd5'), { recursive: true }); + writeFileSync( + join(dir, 'd5', 'hook.yaml'), + [ + 'id: D5', + 'name: Test D5', + 'stage: per-turn', + 'order: 500', + 'version: 1', + 'enabled: true', + 'template: d5.md', + 'inputs: []', + 'disableable: true', + 'safetyTier: limited-edit', + 'transparencyTier: visible-by-default', + 'governanceTier: human-gated', + ].join('\n'), + ); + writeFileSync(join(dir, 'd5', 'd5.md'), ''); + + const registry = new HookRegistry(dir); + registry.scan(); + + // Inject override with auto-eval content on limited-edit hook + const snapshot = new Map(); + snapshot.set('D5', { + hookId: 'D5', + contentOverride: 'auto-eval injected content', + contentVersion: 2, + contentSource: 'auto-eval', + source: 'operator', // corrupted by subsequent enable() + updatedAt: Date.now(), + updatedBy: 'eval-bot', + }); + registry.setOverrideSnapshot(snapshot); + + assert.equal( + registry.getContentOverride('D5'), + undefined, + 'auto-eval content blocked on limited-edit hook despite source=operator', + ); + + rmSync(dir, { recursive: true, force: true }); + }); + + test('getContentOverride honors operator content on limited-edit hook', async () => { + const { mkdirSync, writeFileSync, rmSync } = await import('node:fs'); + const { join } = await import('node:path'); + const { HookRegistry } = await import('../dist/domains/prompt-hooks/HookRegistry.js'); + + const dir = join(import.meta.dirname, '__fixtures__', 'sol-r2-limited-ok'); + rmSync(dir, { recursive: true, force: true }); + mkdirSync(join(dir, 'd5'), { recursive: true }); + writeFileSync( + join(dir, 'd5', 'hook.yaml'), + [ + 'id: D5', + 'name: Test D5', + 'stage: per-turn', + 'order: 500', + 'version: 1', + 'enabled: true', + 'template: d5.md', + 'inputs: []', + 'disableable: true', + 'safetyTier: limited-edit', + 'transparencyTier: visible-by-default', + 'governanceTier: human-gated', + ].join('\n'), + ); + writeFileSync(join(dir, 'd5', 'd5.md'), ''); + + const registry = new HookRegistry(dir); + registry.scan(); + + const snapshot = new Map(); + snapshot.set('D5', { + hookId: 'D5', + contentOverride: 'operator-approved content', + contentVersion: 1, + contentSource: 'operator', + source: 'auto-eval', // corrupted by subsequent disable() + updatedAt: Date.now(), + updatedBy: 'human', + }); + registry.setOverrideSnapshot(snapshot); + + assert.equal( + registry.getContentOverride('D5'), + 'operator-approved content', + 'operator content honored on limited-edit hook despite source=auto-eval', + ); + + rmSync(dir, { recursive: true, force: true }); + }); + + test('getDisabledBySource uses enabledSource over shared source', async () => { + const { mkdirSync, writeFileSync, rmSync } = await import('node:fs'); + const { join } = await import('node:path'); + const { HookRegistry } = await import('../dist/domains/prompt-hooks/HookRegistry.js'); + + const dir = join(import.meta.dirname, '__fixtures__', 'sol-r2-disabled-by'); + rmSync(dir, { recursive: true, force: true }); + mkdirSync(join(dir, 'd5'), { recursive: true }); + writeFileSync( + join(dir, 'd5', 'hook.yaml'), + [ + 'id: D5', + 'name: Test D5', + 'stage: per-turn', + 'order: 500', + 'version: 1', + 'enabled: true', + 'template: d5.md', + 'inputs: []', + 'disableable: true', + 'safetyTier: limited-edit', + 'transparencyTier: visible-by-default', + 'governanceTier: human-gated', + ].join('\n'), + ); + writeFileSync(join(dir, 'd5', 'd5.md'), ''); + + const registry = new HookRegistry(dir); + registry.scan(); + + // auto-eval disabled, then operator set content (corrupting shared source to 'operator') + const snapshot = new Map(); + snapshot.set('D5', { + hookId: 'D5', + enabled: false, + enabledSource: 'auto-eval', + contentOverride: 'operator content', + contentSource: 'operator', + source: 'operator', // corrupted by setContentOverride + updatedAt: Date.now(), + updatedBy: 'human', + }); + registry.setOverrideSnapshot(snapshot); + + assert.equal( + registry.getDisabledBySource('D5'), + 'auto-eval', + 'disabledBy uses enabledSource, not corrupted shared source', + ); + + rmSync(dir, { recursive: true, force: true }); + }); + + // -- getActiveVersion consistency with getContentOverride (sol P2) ---------- + + test('getActiveVersion returns manifest version when content rejected (limited-edit + auto-eval)', async () => { + const { mkdirSync, writeFileSync, rmSync } = await import('node:fs'); + const { join } = await import('node:path'); + const { HookRegistry } = await import('../dist/domains/prompt-hooks/HookRegistry.js'); + + const dir = join(import.meta.dirname, '__fixtures__', 'sol-p2-version-rejected'); + rmSync(dir, { recursive: true, force: true }); + mkdirSync(join(dir, 'd5'), { recursive: true }); + writeFileSync( + join(dir, 'd5', 'hook.yaml'), + [ + 'id: D5', + 'name: Test D5', + 'stage: per-turn', + 'order: 500', + 'version: 1', + 'enabled: true', + 'template: d5.md', + 'inputs: []', + 'disableable: true', + 'safetyTier: limited-edit', + 'transparencyTier: visible-by-default', + 'governanceTier: human-gated', + ].join('\n'), + ); + writeFileSync(join(dir, 'd5', 'd5.md'), ''); + + const registry = new HookRegistry(dir); + registry.scan(); + + // auto-eval set content (contentVersion=99), then operator enabled (corrupting source) + const snapshot = new Map(); + snapshot.set('D5', { + hookId: 'D5', + enabled: true, + enabledSource: 'operator', + contentOverride: 'auto-eval injected v99', + contentVersion: 99, + contentSource: 'auto-eval', + source: 'operator', + updatedAt: Date.now(), + updatedBy: 'eval-bot', + }); + registry.setOverrideSnapshot(snapshot); + + // Content must be rejected (limited-edit + auto-eval source) + assert.equal(registry.getContentOverride('D5'), undefined, 'content rejected'); + // Version must match what is rendered (manifest v1), NOT stale override v99 + assert.equal( + registry.getActiveVersion('D5'), + 1, + 'getActiveVersion returns manifest version when content is rejected (sol P2)', + ); + + rmSync(dir, { recursive: true, force: true }); + }); + + test('getActiveVersion returns override version when content honored (limited-edit + operator)', async () => { + const { mkdirSync, writeFileSync, rmSync } = await import('node:fs'); + const { join } = await import('node:path'); + const { HookRegistry } = await import('../dist/domains/prompt-hooks/HookRegistry.js'); + + const dir = join(import.meta.dirname, '__fixtures__', 'sol-p2-version-honored'); + rmSync(dir, { recursive: true, force: true }); + mkdirSync(join(dir, 'd5'), { recursive: true }); + writeFileSync( + join(dir, 'd5', 'hook.yaml'), + [ + 'id: D5', + 'name: Test D5', + 'stage: per-turn', + 'order: 500', + 'version: 1', + 'enabled: true', + 'template: d5.md', + 'inputs: []', + 'disableable: true', + 'safetyTier: limited-edit', + 'transparencyTier: visible-by-default', + 'governanceTier: human-gated', + ].join('\n'), + ); + writeFileSync(join(dir, 'd5', 'd5.md'), ''); + + const registry = new HookRegistry(dir); + registry.scan(); + + const snapshot = new Map(); + snapshot.set('D5', { + hookId: 'D5', + contentOverride: 'operator-approved content v3', + contentVersion: 3, + contentSource: 'operator', + source: 'operator', + updatedAt: Date.now(), + updatedBy: 'human', + }); + registry.setOverrideSnapshot(snapshot); + + // Content must be honored (operator source on limited-edit) + assert.equal(registry.getContentOverride('D5'), 'operator-approved content v3', 'content honored'); + // Version must match override + assert.equal( + registry.getActiveVersion('D5'), + 3, + 'getActiveVersion returns override version when content is honored', + ); + + rmSync(dir, { recursive: true, force: true }); + }); + + test('getActiveVersion returns manifest version on readonly hook with stale override', async () => { + const { mkdirSync, writeFileSync, rmSync } = await import('node:fs'); + const { join } = await import('node:path'); + const { HookRegistry } = await import('../dist/domains/prompt-hooks/HookRegistry.js'); + + const dir = join(import.meta.dirname, '__fixtures__', 'sol-p2-version-readonly'); + rmSync(dir, { recursive: true, force: true }); + mkdirSync(join(dir, 'd5'), { recursive: true }); + writeFileSync( + join(dir, 'd5', 'hook.yaml'), + [ + 'id: D5', + 'name: Test D5', + 'stage: per-turn', + 'order: 500', + 'version: 2', + 'enabled: true', + 'template: d5.md', + 'inputs: []', + 'disableable: false', + 'safetyTier: readonly', + 'transparencyTier: visible-by-default', + 'governanceTier: human-gated', + ].join('\n'), + ); + writeFileSync(join(dir, 'd5', 'd5.md'), ''); + + const registry = new HookRegistry(dir); + registry.scan(); + + // Stale override from before manifest tightened to readonly + const snapshot = new Map(); + snapshot.set('D5', { + hookId: 'D5', + contentOverride: 'stale content from editable era', + contentVersion: 50, + contentSource: 'operator', + source: 'operator', + updatedAt: Date.now(), + updatedBy: 'human', + }); + registry.setOverrideSnapshot(snapshot); + + assert.equal(registry.getContentOverride('D5'), undefined, 'readonly rejects content'); + assert.equal( + registry.getActiveVersion('D5'), + 2, + 'getActiveVersion returns manifest version on readonly hook (sol P2)', + ); + + rmSync(dir, { recursive: true, force: true }); + }); +}); + +describe('Same-ms event ordering (R5 P1-1)', () => { + test('event IDs sort by seq, not action name', async () => { + const s1 = makeManifest('S1-order'); + const fakeRedis = new FakeRedis(); + const mod = await import('../dist/domains/prompt-hooks/HookOverrideStore.js'); + const lookup = (id) => (id === s1.id ? s1 : undefined); + const orderStore = new mod.HookOverrideStore(fakeRedis, lookup); + + const frozenMs = 1700000000000; + const origNow = Date.now; + Date.now = () => frozenMs; + try { + await orderStore.setContentOverride(s1.id, 'v1', 'operator', 'u1'); + await orderStore.rollback(s1.id, 'u1'); + await orderStore.setContentOverride(s1.id, 'v2', 'operator', 'u1'); + } finally { + Date.now = origNow; + } + + const events = await orderStore.listEvents({ limit: 100 }); + const actions = events.map((e) => e.action); + assert.equal(actions[0], 'content-set', 'first: content-set (v1)'); + assert.equal(actions[1], 'rollback', 'second: rollback'); + assert.equal(actions[2], 'content-set', 'third: content-set (v2)'); + }); +}); + +describe('P1-3 R6: epochVersion-based version management', () => { + test('snapshots keyed by epochVersion (manifest.version+N), activateVersion restores by epochVersion', async () => { + const s1 = makeManifest('S1-ver'); + const fakeRedis = new FakeRedis(); + const mod = await import('../dist/domains/prompt-hooks/HookOverrideStore.js'); + const lookup = (id) => (id === s1.id ? s1 : undefined); + const store = new mod.HookOverrideStore(fakeRedis, lookup); + + // manifest.version=1, so first override epochVersion=2, second=3 + await store.setContentOverride(s1.id, 'content-A', 'u1'); + await store.setContentOverride(s1.id, 'content-B', 'u1'); + + const beforeActivate = await store.getOverride(s1.id); + assert.equal(beforeActivate.contentVersion, 2); + assert.equal(beforeActivate.contentOverride, 'content-B'); + + // Activate epochVersion=2 (first override) — content should restore to A + await store.activateVersion(s1.id, 2, 'u1'); + + const afterActivate = await store.getOverride(s1.id); + assert.equal(afterActivate.contentOverride, 'content-A', 'content should be first override'); + // contentVersion stays at 2 (edit counter, not identity) + assert.equal(afterActivate.contentVersion, 2, 'contentVersion is edit count, not reset'); + }); + + test('epochVersion is monotonic: activate→set creates new epochVersion, no collision', async () => { + const s1 = makeManifest('S1-mono'); + const fakeRedis = new FakeRedis(); + const mod = await import('../dist/domains/prompt-hooks/HookOverrideStore.js'); + const lookup = (id) => (id === s1.id ? s1 : undefined); + const store = new mod.HookOverrideStore(fakeRedis, lookup); + + await store.setContentOverride(s1.id, 'A', 'u1'); // epochVersion=2 + await store.setContentOverride(s1.id, 'B', 'u1'); // epochVersion=3 + await store.activateVersion(s1.id, 2, 'u1'); // restore A + await store.setContentOverride(s1.id, 'C', 'u1'); // epochVersion=4 (NOT 3!) + + const versions = await store.listVersions(s1.id); + assert.equal(versions.length, 3, 'should have 3 snapshots (2,3,4)'); + assert.equal(versions[0].version, 2); + assert.ok(versions[0].contentPreview.includes('A')); + assert.equal(versions[1].version, 3); + assert.ok(versions[1].contentPreview.includes('B'), 'B must NOT be overwritten by C'); + assert.equal(versions[2].version, 4); + assert.ok(versions[2].contentPreview.includes('C')); + }); + + test('version-activate event carries epochVersion, not contentVersion', async () => { + const s1 = makeManifest('S1-evt'); + const fakeRedis = new FakeRedis(); + const mod = await import('../dist/domains/prompt-hooks/HookOverrideStore.js'); + const lookup = (id) => (id === s1.id ? s1 : undefined); + const store = new mod.HookOverrideStore(fakeRedis, lookup); + + await store.setContentOverride(s1.id, 'v1-content', 'u1'); + await store.setContentOverride(s1.id, 'v2-content', 'u1'); + await store.activateVersion(s1.id, 2, 'u1', { reason: 'reverting' }); + + const events = await store.listEvents({ limit: 100 }); + const activateEvent = events.find((e) => e.action === 'version-activate'); + assert.ok(activateEvent, 'version-activate event should exist'); + assert.equal(activateEvent.epochVersion, 2, 'event should carry epochVersion'); + assert.equal(activateEvent.contentVersion, undefined, 'contentVersion should NOT be on activate events'); + assert.equal(activateEvent.reason, 'reverting'); + }); + + test('content-set events carry epochVersion', async () => { + const s1 = makeManifest('S1-cs-epoch'); + const fakeRedis = new FakeRedis(); + const mod = await import('../dist/domains/prompt-hooks/HookOverrideStore.js'); + const lookup = (id) => (id === s1.id ? s1 : undefined); + const store = new mod.HookOverrideStore(fakeRedis, lookup); + + await store.setContentOverride(s1.id, 'hello', 'u1'); + const events = await store.listEvents({ limit: 100 }); + assert.equal(events[0].epochVersion, 2, 'first override epochVersion = manifest.version + 1'); + assert.equal(events[0].contentVersion, 1, 'contentVersion is edit count (1)'); + }); + + test('activateVersion throws for nonexistent epochVersion', async () => { + const s1 = makeManifest('S1-nosnap'); + const fakeRedis = new FakeRedis(); + const mod = await import('../dist/domains/prompt-hooks/HookOverrideStore.js'); + const lookup = (id) => (id === s1.id ? s1 : undefined); + const store = new mod.HookOverrideStore(fakeRedis, lookup); + + await assert.rejects(() => store.activateVersion(s1.id, 99, 'u1'), /No content snapshot/); + }); + + test('listVersions returns all epochVersion snapshots in order', async () => { + const s1 = makeManifest('S1-list'); + const fakeRedis = new FakeRedis(); + const mod = await import('../dist/domains/prompt-hooks/HookOverrideStore.js'); + const lookup = (id) => (id === s1.id ? s1 : undefined); + const store = new mod.HookOverrideStore(fakeRedis, lookup); + + await store.setContentOverride(s1.id, 'alpha', 'u1'); + await store.setContentOverride(s1.id, 'beta', 'u1'); + + const versions = await store.listVersions(s1.id); + assert.equal(versions.length, 2); + assert.equal(versions[0].version, 2, 'first epochVersion = manifest+1 = 2'); + assert.ok(versions[0].contentPreview.includes('alpha')); + assert.equal(versions[1].version, 3, 'second epochVersion = 3'); + }); + + // ── R7 regression: activeEpochVersion propagation ── + + test('setContentOverride sets activeEpochVersion on the override (R7)', async () => { + const s1 = makeManifest('S1-aev-set'); + const fakeRedis = new FakeRedis(); + const mod = await import('../dist/domains/prompt-hooks/HookOverrideStore.js'); + const lookup = (id) => (id === s1.id ? s1 : undefined); + const store = new mod.HookOverrideStore(fakeRedis, lookup); + + await store.setContentOverride(s1.id, 'v2 content', 'u1'); + const override = await store.getOverride(s1.id); + assert.equal(override.activeEpochVersion, 2, 'activeEpochVersion = epochVersion from setContentOverride'); + + await store.setContentOverride(s1.id, 'v3 content', 'u1'); + const override2 = await store.getOverride(s1.id); + assert.equal(override2.activeEpochVersion, 3, 'activeEpochVersion advances with each setContentOverride'); + }); + + test('activateVersion sets activeEpochVersion on the override (R7)', async () => { + const s1 = makeManifest('S1-aev-activate'); + const fakeRedis = new FakeRedis(); + const mod = await import('../dist/domains/prompt-hooks/HookOverrideStore.js'); + const lookup = (id) => (id === s1.id ? s1 : undefined); + const store = new mod.HookOverrideStore(fakeRedis, lookup); + + await store.setContentOverride(s1.id, 'v2 content', 'u1'); + await store.setContentOverride(s1.id, 'v3 content', 'u1'); + const before = await store.getOverride(s1.id); + assert.equal(before.activeEpochVersion, 3, 'should be at epoch 3'); + + // Activate v2 — activeEpochVersion should switch to 2 + await store.activateVersion(s1.id, 2, 'u1', { reason: 'rollback to v2' }); + const after = await store.getOverride(s1.id); + assert.equal(after.activeEpochVersion, 2, 'activateVersion should set activeEpochVersion to target'); + }); + + test('clearContentOverride removes activeEpochVersion (R7)', async () => { + const s1 = makeManifest('S1-aev-clear'); + const fakeRedis = new FakeRedis(); + const mod = await import('../dist/domains/prompt-hooks/HookOverrideStore.js'); + const lookup = (id) => (id === s1.id ? s1 : undefined); + const store = new mod.HookOverrideStore(fakeRedis, lookup); + + await store.setContentOverride(s1.id, 'v2 content', 'u1'); + const before = await store.getOverride(s1.id); + assert.equal(before.activeEpochVersion, 2, 'has activeEpochVersion before clear'); + + await store.clearContentOverride(s1.id, 'u1'); + const after = await store.getOverride(s1.id); + assert.equal(after.activeEpochVersion, undefined, 'activeEpochVersion removed after clear'); + }); + + test('rollback removes activeEpochVersion (R7)', async () => { + const s1 = makeManifest('S1-aev-rollback'); + const fakeRedis = new FakeRedis(); + const mod = await import('../dist/domains/prompt-hooks/HookOverrideStore.js'); + const lookup = (id) => (id === s1.id ? s1 : undefined); + const store = new mod.HookOverrideStore(fakeRedis, lookup); + + await store.setContentOverride(s1.id, 'v2 content', 'u1'); + await store.rollback(s1.id, 'u1'); + const after = await store.getOverride(s1.id); + assert.equal(after, null, 'rollback deletes entire override'); + }); + + test('concurrent setContentOverride gets distinct epochVersions — no collision (R7)', async () => { + const s1 = makeManifest('S1-concurrent'); + const fakeRedis = new FakeRedis(); + const mod = await import('../dist/domains/prompt-hooks/HookOverrideStore.js'); + const lookup = (id) => (id === s1.id ? s1 : undefined); + const store = new mod.HookOverrideStore(fakeRedis, lookup); + + // Fire two setContentOverride concurrently — both use SETNX+INCR so + // they should get distinct epoch versions and not overwrite snapshots. + await Promise.all([ + store.setContentOverride(s1.id, 'concurrent-A', 'u1'), + store.setContentOverride(s1.id, 'concurrent-B', 'u2'), + ]); + + const versions = await store.listVersions(s1.id); + assert.equal(versions.length, 2, 'both concurrent writes created distinct snapshots'); + + const epochVersions = versions.map((v) => v.version); + assert.notEqual(epochVersions[0], epochVersions[1], 'epochVersions must differ'); + + // Both contents should be preserved (no overwrite) + const contents = versions.map((v) => v.contentPreview); + assert.ok( + contents.includes('concurrent-A') && contents.includes('concurrent-B'), + 'both snapshot contents must be preserved', + ); + }); +}); diff --git a/packages/api/test/hook-pipeline.test.js b/packages/api/test/hook-pipeline.test.js index 4503c075dd..a2532d2c94 100644 --- a/packages/api/test/hook-pipeline.test.js +++ b/packages/api/test/hook-pipeline.test.js @@ -66,6 +66,28 @@ function makeInput(overrides = {}) { }; } +/** + * Wrap a minimal mock registry (getStageHooks only) with the override-aware + * methods HookPipeline now calls (PR3: isEnabled/getActiveVersion/ + * getDisabledBySource/getContentOverride). Defaults to manifest baseline. + */ +function withOverrideMethods(mockRegistry) { + // Build a hooks map from the stageHooks the mock returns + const hooksMap = new Map(); + for (const stage of ['session-init', 'per-turn']) { + for (const h of mockRegistry.getStageHooks(stage)) { + hooksMap.set(h.manifest.id, h); + } + } + return { + ...mockRegistry, + isEnabled: (hookId) => hooksMap.get(hookId)?.manifest.enabled ?? false, + getActiveVersion: (hookId) => hooksMap.get(hookId)?.manifest.version ?? 0, + getDisabledBySource: () => 'manifest', + getContentOverride: () => undefined, + }; +} + describe('HookPipeline', () => { /** @type {typeof import('../dist/domains/prompt-hooks/HookPipeline.js')} */ let pipelineMod; @@ -116,7 +138,7 @@ describe('HookPipeline', () => { // Mock renderer const renderer = (id, vars) => `[${id}] name=${vars.NAME ?? ''}`; - const pipeline = new pipelineMod.HookPipeline(mockRegistry, resolvers, renderer); + const pipeline = new pipelineMod.HookPipeline(withOverrideMethods(mockRegistry), resolvers, renderer); const result = pipeline.executeStage('per-turn', makeInput()); // D1 should fire, D2 should skip @@ -144,7 +166,7 @@ describe('HookPipeline', () => { }, ], }; - const pipeline = new pipelineMod.HookPipeline(mockRegistry, new Map(), () => 'content'); + const pipeline = new pipelineMod.HookPipeline(withOverrideMethods(mockRegistry), new Map(), () => 'content'); const result = pipeline.executeStage('session-init', makeInput()); assert.equal(result.patches.length, 0); @@ -174,7 +196,7 @@ describe('HookPipeline', () => { return `mode=${id} idx=${vars.CHAIN_INDEX}`; }; - const pipeline = new pipelineMod.HookPipeline(mockRegistry, resolvers, renderer); + const pipeline = new pipelineMod.HookPipeline(withOverrideMethods(mockRegistry), resolvers, renderer); const result = pipeline.executeStage('per-turn', makeInput()); // Renderer should be called with 'D7_serial', not 'D7' @@ -198,7 +220,7 @@ describe('HookPipeline', () => { // Renderer returns null = template missing const renderer = () => null; - const pipeline = new pipelineMod.HookPipeline(mockRegistry, resolvers, renderer); + const pipeline = new pipelineMod.HookPipeline(withOverrideMethods(mockRegistry), resolvers, renderer); const result = pipeline.executeStage('per-turn', makeInput()); assert.equal(result.patches.length, 0); @@ -219,7 +241,7 @@ describe('HookPipeline', () => { }; // No resolver for L1 const renderer = () => 'governance content'; - const pipeline = new pipelineMod.HookPipeline(mockRegistry, new Map(), renderer); + const pipeline = new pipelineMod.HookPipeline(withOverrideMethods(mockRegistry), new Map(), renderer); const result = pipeline.executeStage('session-init', makeInput()); assert.equal(result.patches.length, 1); @@ -236,7 +258,7 @@ describe('HookPipeline', () => { }); it('empty stage produces no patches or events', () => { - const mockRegistry = { getStageHooks: () => [] }; + const mockRegistry = withOverrideMethods({ getStageHooks: () => [] }); const pipeline = new pipelineMod.HookPipeline(mockRegistry, new Map(), () => 'x'); const result = pipeline.executeStage('session-init', makeInput()); diff --git a/packages/api/test/image-upload.test.js b/packages/api/test/image-upload.test.js index 87e5758678..095871631f 100644 --- a/packages/api/test/image-upload.test.js +++ b/packages/api/test/image-upload.test.js @@ -284,6 +284,7 @@ describe('contentBlocks in GET /api/messages', () => { it('returns contentBlocks when present', async () => { messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'default-user', catId: null, content: 'check this image', @@ -306,6 +307,7 @@ describe('contentBlocks in GET /api/messages', () => { it('omits contentBlocks when not present', async () => { messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'default-user', catId: null, content: 'text only', @@ -344,6 +346,13 @@ describe('multipart image target routing', () => { const mockRouter = { async resolveTargetsAndIntent() { return { + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, targetCats: ['opus'], intent: { intent: 'execute', explicit: false, promptTags: [] }, }; diff --git a/packages/api/test/injection-trace-store.test.js b/packages/api/test/injection-trace-store.test.js index 319ac6e638..64dbab330e 100644 --- a/packages/api/test/injection-trace-store.test.js +++ b/packages/api/test/injection-trace-store.test.js @@ -5,12 +5,44 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -// ── FakeRedis with sorted set support ── +// ── FakeRedis with sorted set + pipeline support ── + +class FakePipeline { + constructor(redis) { + this.redis = redis; + this.ops = []; + } + + set(key, value) { + this.ops.push(async () => this.redis.set(key, value)); + return this; + } + + del(key) { + this.ops.push(async () => this.redis.del(key)); + return this; + } + + sadd(key, ...members) { + this.ops.push(async () => this.redis.sadd(key, ...members)); + return this; + } + + async exec() { + const results = []; + for (const op of this.ops) { + results.push([null, await op()]); + } + return results; + } +} class FakeRedis { constructor() { this.kv = new Map(); this.sorted = new Map(); // key → Map + this.sets = new Map(); + this.hashes = new Map(); this.ttls = new Map(); } @@ -26,10 +58,21 @@ class FakeRedis { return this.kv.get(key) ?? null; } + async exists(key) { + if (this.kv.has(key)) return 1; + if (this.sorted.has(key)) return 1; + if (this.sets?.has(key)) return 1; + if (this.hashes.has(key)) return 1; + return 0; + } + async del(key) { - const existed = this.kv.has(key) ? 1 : 0; + const existed = this.kv.has(key) || this.sorted.has(key) || this.sets?.has(key) || this.hashes.has(key) ? 1 : 0; this.kv.delete(key); this.ttls.delete(key); + this.sorted.delete(key); + this.sets?.delete(key); + this.hashes.delete(key); return existed; } @@ -51,11 +94,138 @@ class FakeRedis { return entries.slice(start, stop + 1).map(([member]) => member); } + async zrangebyscore(key, min, max) { + const set = this.sorted.get(key); + if (!set) return []; + return [...set.entries()] + .filter(([, score]) => score >= min && score <= max) + .sort((a, b) => a[1] - b[1]) + .map(([member]) => member); + } + async zrem(key, member) { const set = this.sorted.get(key); if (!set) return 0; return set.delete(member) ? 1 : 0; } + + multi() { + return new FakePipeline(this); + } + + // F257 Phase D: prefix-aware SCAN for backfillRegistry. + // ioredis scan returns [cursor, keys]; MATCH pattern is applied against raw keys. + async scan(cursor, ...args) { + const options = Object.fromEntries( + Array.from({ length: Math.floor(args.length / 2) }, (_, i) => [args[i * 2], args[i * 2 + 1]]), + ); + const pattern = options.MATCH ? new RegExp(options.MATCH.replace(/\*/g, '.*')) : null; + const count = options.COUNT ? Number(options.COUNT) : 10; + + const allKeys = [...new Set([...this.kv.keys(), ...this.sorted.keys(), ...(this.sets?.keys() ?? [])])]; + const matched = pattern ? allKeys.filter((k) => pattern.test(k)) : allKeys; + const start = Number(cursor) || 0; + const next = Math.min(start + count, matched.length); + return [String(next), matched.slice(start, next)]; + } + + // F257 Phase D: SADD/SMEMBERS for thread registry (persist() now calls sadd). + async sadd(key, ...members) { + if (!this.sets) { + this.sets = new Map(); + } + const s = this.sets; + const existing = s.get(key) ?? new Set(); + let added = 0; + for (const m of members) { + if (!existing.has(m)) { + existing.add(m); + added++; + } + } + s.set(key, existing); + return added; + } + + async smembers(key) { + const s = this.sets?.get(key); + return s ? [...s] : []; + } + + // F257 R4: hash + Lua support for durable replay snapshots. + async hset(key, fields) { + const h = this.hashes.get(key) ?? new Map(); + for (const [field, value] of Object.entries(fields)) { + h.set(field, value); + } + this.hashes.set(key, h); + return 1; + } + + async hget(key, field) { + return this.hashes.get(key)?.get(field) ?? null; + } + + async hgetall(key) { + const h = this.hashes.get(key); + if (!h) return []; + const out = []; + for (const [k, v] of h) { + out.push(k, v); + } + return out; + } + + async hdel(key, field) { + const h = this.hashes.get(key); + if (!h) return 0; + return h.delete(field) ? 1 : 0; + } + + #runPersistScript(keys, argv) { + const summaryKey = keys[0]; + const hashKey = keys[1]; + const count = Number(argv[0]); + if (this.kv.has(summaryKey) === false) return 0; + const h = this.hashes.get(hashKey) ?? new Map(); + for (let i = 0; i < count; i++) { + const segmentId = argv[1 + i]; + const json = argv[1 + count + i]; + h.set(segmentId, json); + } + this.hashes.set(hashKey, h); + return 1; + } + + #runDeleteScript(keys, argv) { + const indexKey = keys[2]; + const turnId = argv[0]; + let removed = 0; + if (this.sorted.get(indexKey)?.delete(turnId)) removed = 1; + for (const k of keys) { + if ( + k !== indexKey && + (this.kv.delete(k) || this.sets?.delete(k) || this.sorted.delete(k) || this.hashes.delete(k)) + ) { + removed++; + } + } + return removed; + } + + async eval(script, numKeys, ...args) { + const keys = args.slice(0, numKeys); + const argv = args.slice(numKeys); + + if (script.includes("redis.call('EXISTS'") && script.includes("redis.call('HSET'")) { + return this.#runPersistScript(keys, argv); + } + if (script.includes("redis.call('ZREM'") && script.includes("redis.call('DEL'")) { + return this.#runDeleteScript(keys, argv); + } + + throw new Error(`FakeRedis.eval: unsupported script`); + } } // ── InjectionTraceStore tests ── @@ -276,6 +446,230 @@ describe('InjectionTraceStore', () => { assert.equal(total, 0); }); + test('deleteTurn does not remove sibling turns from thread index', async () => { + const { InjectionTraceStore } = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + const redis = new FakeRedis(); + const store = new InjectionTraceStore(redis); + + const base = { + sessionId: 's1', + threadId: 'th1', + catId: 'c1', + segments: [], + delivery: [], + totalCharCount: 0, + totalTokenEstimate: 0, + totalSegmentsObserved: 0, + totalSegmentsAbsent: 0, + durationMs: 0, + }; + const baseDetail = { + threadId: 'th1', + catId: 'c1', + sessionContentHash: null, + turnContentHash: null, + sessionCharCount: 0, + sessionTokenEstimate: 0, + turnCharCount: 0, + turnTokenEstimate: 0, + segments: [], + }; + + await store.persist( + { ...base, turnId: 'turn-a', timestamp: 1000 }, + { ...baseDetail, turnId: 'turn-a', timestamp: 1000 }, + ); + await store.persist( + { ...base, turnId: 'turn-b', timestamp: 2000 }, + { ...baseDetail, turnId: 'turn-b', timestamp: 2000 }, + ); + + await store.deleteTurn('th1', 'turn-a'); + + assert.equal(await store.getSummary('th1', 'turn-a'), null); + assert.equal(await store.getDetail('th1', 'turn-a'), null); + + const { turnIds, total } = await store.listTurnIds('th1'); + assert.equal(total, 1); + assert.deepEqual(turnIds, ['turn-b']); + + const remainingSummary = await store.getSummary('th1', 'turn-b'); + assert.ok(remainingSummary); + assert.equal(remainingSummary.turnId, 'turn-b'); + + const window = await store.queryWindow('th1', 1500, 2500); + assert.equal(window.length, 1); + assert.equal(window[0].turnId, 'turn-b'); + }); + + test('queryWindow returns summaries within time range', async () => { + const { InjectionTraceStore } = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + const redis = new FakeRedis(); + const store = new InjectionTraceStore(redis); + + const base = { + sessionId: 's1', + threadId: 'th1', + catId: 'ragdoll', + segments: [ + { + segmentId: 'S1', + stage: 'session-init', + status: 'observed', + contentHash: 'a', + charCount: 10, + tokenEstimate: 3, + }, + ], + delivery: [], + totalCharCount: 10, + totalTokenEstimate: 3, + totalSegmentsObserved: 1, + totalSegmentsAbsent: 0, + durationMs: 0, + }; + const baseDetail = { + threadId: 'th1', + catId: 'ragdoll', + sessionContentHash: 'a', + turnContentHash: null, + sessionCharCount: 10, + sessionTokenEstimate: 3, + turnCharCount: 0, + turnTokenEstimate: 0, + segments: base.segments, + }; + + // Three turns: ts=1000 (before), ts=2000 (in window), ts=3000 (in window), ts=5000 (after) + await store.persist( + { ...base, turnId: 'before', timestamp: 1000 }, + { ...baseDetail, turnId: 'before', timestamp: 1000 }, + ); + await store.persist( + { ...base, turnId: 'in-1', timestamp: 2000 }, + { ...baseDetail, turnId: 'in-1', timestamp: 2000 }, + ); + await store.persist( + { ...base, turnId: 'in-2', timestamp: 3000 }, + { ...baseDetail, turnId: 'in-2', timestamp: 3000 }, + ); + await store.persist( + { ...base, turnId: 'after', timestamp: 5000 }, + { ...baseDetail, turnId: 'after', timestamp: 5000 }, + ); + + // Query window [1500, 4000] — should include in-1 (2000) and in-2 (3000), exclude before (1000) and after (5000) + const results = await store.queryWindow('th1', 1500, 4000); + assert.equal(results.length, 2); + assert.equal(results[0].turnId, 'in-1'); + assert.equal(results[1].turnId, 'in-2'); + }); + + test('queryWindow returns empty for no matches', async () => { + const { InjectionTraceStore } = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + const redis = new FakeRedis(); + const store = new InjectionTraceStore(redis); + + const base = { + sessionId: 's1', + threadId: 'th1', + catId: 'ragdoll', + segments: [], + delivery: [], + totalCharCount: 0, + totalTokenEstimate: 0, + totalSegmentsObserved: 0, + totalSegmentsAbsent: 0, + durationMs: 0, + }; + const baseDetail = { + threadId: 'th1', + catId: 'ragdoll', + sessionContentHash: null, + turnContentHash: null, + sessionCharCount: 0, + sessionTokenEstimate: 0, + turnCharCount: 0, + turnTokenEstimate: 0, + segments: [], + }; + + await store.persist({ ...base, turnId: 't1', timestamp: 1000 }, { ...baseDetail, turnId: 't1', timestamp: 1000 }); + + // Window entirely before or after existing data + const before = await store.queryWindow('th1', 0, 500); + assert.equal(before.length, 0); + const after = await store.queryWindow('th1', 2000, 3000); + assert.equal(after.length, 0); + }); + + test('queryWindow boundary: start-inclusive, end-exclusive [start, end)', async () => { + const { InjectionTraceStore } = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + const redis = new FakeRedis(); + const store = new InjectionTraceStore(redis); + + const base = { + sessionId: 's1', + threadId: 'th1', + catId: 'ragdoll', + segments: [], + delivery: [], + totalCharCount: 0, + totalTokenEstimate: 0, + totalSegmentsObserved: 0, + totalSegmentsAbsent: 0, + durationMs: 0, + }; + const baseDetail = { + threadId: 'th1', + catId: 'ragdoll', + sessionContentHash: null, + turnContentHash: null, + sessionCharCount: 0, + sessionTokenEstimate: 0, + turnCharCount: 0, + turnTokenEstimate: 0, + segments: [], + }; + + await store.persist( + { ...base, turnId: 'at-start', timestamp: 1000 }, + { ...baseDetail, turnId: 'at-start', timestamp: 1000 }, + ); + await store.persist( + { ...base, turnId: 'at-end', timestamp: 2000 }, + { ...baseDetail, turnId: 'at-end', timestamp: 2000 }, + ); + + // [1000, 2000): includes start boundary (1000), excludes end boundary (2000) + // Matches GuardRejectionEventLog.queryWindow contract. + const results = await store.queryWindow('th1', 1000, 2000); + assert.equal(results.length, 1); + assert.equal(results[0].turnId, 'at-start'); + + // Start-inclusive: exact start boundary included + const startExact = await store.queryWindow('th1', 1000, 1001); + assert.equal(startExact.length, 1); + assert.equal(startExact[0].turnId, 'at-start'); + + // End-exclusive: [1000, 1000) is empty range + const emptyRange = await store.queryWindow('th1', 1000, 1000); + assert.equal(emptyRange.length, 0); + + // End-inclusive requires end+1: [1000, 2001) includes both + const bothInclusive = await store.queryWindow('th1', 1000, 2001); + assert.equal(bothInclusive.length, 2); + }); + + test('queryWindow returns empty for unknown threadId', async () => { + const { InjectionTraceStore } = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + const redis = new FakeRedis(); + const store = new InjectionTraceStore(redis); + + const results = await store.queryWindow('nonexistent', 0, 999999); + assert.equal(results.length, 0); + }); + test('getSummary returns null for missing key', async () => { const { InjectionTraceStore } = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); const redis = new FakeRedis(); diff --git a/packages/api/test/integration/cross-cat-context.test.js b/packages/api/test/integration/cross-cat-context.test.js index 06e4c315f7..612e21f35c 100644 --- a/packages/api/test/integration/cross-cat-context.test.js +++ b/packages/api/test/integration/cross-cat-context.test.js @@ -127,6 +127,7 @@ describe('Cross-Cat Context (暗号测试)', () => { // Seed 25 messages directly into messageStore for (let i = 0; i < 25; i++) { await messageStore.append({ + provenance: { author: i % 2 === 0 ? 'user' : 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: i % 2 === 0 ? null : 'opus', content: `history-msg-${i}`, diff --git a/packages/api/test/integration/history.test.js b/packages/api/test/integration/history.test.js index 7e3e3cf447..20c75454a6 100644 --- a/packages/api/test/integration/history.test.js +++ b/packages/api/test/integration/history.test.js @@ -36,6 +36,7 @@ describe('POST → GET /api/messages roundtrip', () => { it('messages stored via append() are returned by GET', async () => { // Simulate what AgentRouter does: store user msg + cat reply messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'default-user', catId: null, content: 'hello @opus', @@ -43,6 +44,7 @@ describe('POST → GET /api/messages roundtrip', () => { timestamp: 1000, }); messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'default-user', catId: 'opus', content: 'hello human', @@ -66,6 +68,7 @@ describe('POST → GET /api/messages roundtrip', () => { // Insert 5 messages for (let i = 0; i < 5; i++) { messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'default-user', catId: null, content: `msg ${i}`, @@ -102,6 +105,7 @@ describe('POST → GET /api/messages roundtrip', () => { it('response format matches frontend ChatMessage interface', async () => { messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'default-user', catId: 'codex', content: 'review done', diff --git a/packages/api/test/integration/mcp-prompt-e2e.test.js b/packages/api/test/integration/mcp-prompt-e2e.test.js index c9e0ee3348..929f73d4e4 100644 --- a/packages/api/test/integration/mcp-prompt-e2e.test.js +++ b/packages/api/test/integration/mcp-prompt-e2e.test.js @@ -91,6 +91,7 @@ describe('MCP Prompt Injection E2E', () => { test('injected thread-context endpoint succeeds with real credentials', async () => { // Pre-populate some messages messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: '你好', @@ -99,6 +100,7 @@ describe('MCP Prompt Injection E2E', () => { threadId: 'thread-e2e', }); messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'opus', content: '你好co-creator', diff --git a/packages/api/test/integration/thread-wiring.test.js b/packages/api/test/integration/thread-wiring.test.js index 6d567a5ded..1e398d5e0c 100644 --- a/packages/api/test/integration/thread-wiring.test.js +++ b/packages/api/test/integration/thread-wiring.test.js @@ -169,6 +169,7 @@ describe('Thread isolation: messages stay in their thread', () => { // Add messages to each thread messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'alice', catId: null, content: 'msg in A', @@ -177,6 +178,7 @@ describe('Thread isolation: messages stay in their thread', () => { threadId: threadA.id, }); messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'alice', catId: null, content: 'msg in B', @@ -262,6 +264,7 @@ describe('contentBlocks round-trip: store and retrieve', () => { ]; messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'alice', catId: null, content: 'look at this', @@ -493,6 +496,7 @@ describe('Default thread isolation: no cross-thread message leak', () => { // Store messages in different threads messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'alice', catId: null, content: 'lobby msg', @@ -501,6 +505,7 @@ describe('Default thread isolation: no cross-thread message leak', () => { threadId: 'default', }); messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'alice', catId: null, content: 'thread-B msg', diff --git a/packages/api/test/invocation-queue.test.js b/packages/api/test/invocation-queue.test.js index acf24f2ab5..9ae5e169a7 100644 --- a/packages/api/test/invocation-queue.test.js +++ b/packages/api/test/invocation-queue.test.js @@ -94,6 +94,28 @@ describe('InvocationQueue', () => { assert.equal(queue.list('t1', 'u1')[0].content, 'first'); }); + it('F257 LI-001: queued dedupe upgrades but never drops completionRequirement', () => { + const first = queue.enqueue( + entry({ source: 'connector', idempotencyKey: 'scheduled:same', completionRequirement: undefined }), + ); + assert.equal(first.entry?.completionRequirement, undefined); + + const upgraded = queue.enqueue( + entry({ + source: 'connector', + idempotencyKey: 'scheduled:same', + completionRequirement: 'action-or-routing-exit', + }), + ); + assert.equal(upgraded.deduped, true); + assert.equal(upgraded.entry?.completionRequirement, 'action-or-routing-exit'); + + const replayWithoutPolicy = queue.enqueue( + entry({ source: 'connector', idempotencyKey: 'scheduled:same', completionRequirement: undefined }), + ); + assert.equal(replayWithoutPolicy.entry?.completionRequirement, 'action-or-routing-exit'); + }); + // ── F175: no merge — every entry is independent ── it('same-source same-target entries are independent (F175 no merge)', () => { diff --git a/packages/api/test/invocations-retry.test.js b/packages/api/test/invocations-retry.test.js index 00195a3b2b..1c02c4ec21 100644 --- a/packages/api/test/invocations-retry.test.js +++ b/packages/api/test/invocations-retry.test.js @@ -22,6 +22,13 @@ function createMockRouter(options = {}) { yield { type: 'text', catId: 'opus', content: 'retry response', timestamp: Date.now() }; }, resolveTargetsAndIntent: async () => ({ + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, targetCats: ['opus'], intent: { intent: 'execute', explicit: false, promptTags: [] }, }), @@ -58,6 +65,7 @@ async function setupRetryScenario(routerOverride, trackerOverride) { // Pre-populate: store a user message and create a failed invocation record const storedMsg = messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: '@布偶猫 hello retry', @@ -171,6 +179,7 @@ describe('POST /api/invocations/:id/retry (ADR-008 S2)', () => { const invocationTracker = new InvocationTracker(); const storedMsg = messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: '@布偶猫 queued msg', @@ -225,6 +234,13 @@ describe('POST /api/invocations/:id/retry (ADR-008 S2)', () => { yield { type: 'text', catId: 'opus', content: 'slow retry', timestamp: Date.now() }; }, resolveTargetsAndIntent: async () => ({ + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, targetCats: ['opus'], intent: { intent: 'execute', explicit: false, promptTags: [] }, }), @@ -232,6 +248,7 @@ describe('POST /api/invocations/:id/retry (ADR-008 S2)', () => { }; const storedMsg = messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: '@布偶猫 retry race', @@ -362,6 +379,13 @@ describe('POST /api/invocations/:id/retry (ADR-008 S2)', () => { yield { type: 'text', catId: 'opus', content: 'ok', timestamp: Date.now() }; }, resolveTargetsAndIntent: async () => ({ + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, targetCats: ['opus'], intent: { intent: 'execute', explicit: false, promptTags: [] }, }), @@ -485,6 +509,7 @@ describe('MessageStore.getById()', () => { it('returns message when found', async () => { const store = new MessageStore(); const msg = store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'test message', diff --git a/packages/api/test/l0-compiler.test.js b/packages/api/test/l0-compiler.test.js index 1dc5546b1f..7374d2b2a2 100644 --- a/packages/api/test/l0-compiler.test.js +++ b/packages/api/test/l0-compiler.test.js @@ -98,13 +98,16 @@ test('compileL0ViaSubprocess (no outPath) returns stdout as compiled L0', async const out = await compileL0ViaSubprocess({ catId: 'opus-47', cwd: root, spawnFn }); assert.match(out, /布偶猫/); const call = spawnFn.calls[0]; - assert.deepEqual(call.args, [ + // F257 #2: --manifest-out is always passed (temp path is a UUID → match on presence). + assert.deepEqual(call.args.slice(0, 5), [ resolve(root, SCRIPT_REL), '--cat', 'opus-47', '--profile-dir', resolve(root, 'private/profile'), ]); + const mIdx = call.args.indexOf('--manifest-out'); + assert.ok(mIdx >= 0 && typeof call.args[mIdx + 1] === 'string', 'passes --manifest-out '); assert.ok(!call.args.includes('--out'), 'no --out when outPath omitted'); }); @@ -116,15 +119,17 @@ test('compileL0ViaSubprocess (outPath) passes --out and returns file content', a const out = await compileL0ViaSubprocess({ catId: 'codex', cwd: root, outPath, spawnFn }); assert.equal(out, 'COMPILED-L0-FILE-CONTENT'); const call = spawnFn.calls[0]; - assert.deepEqual(call.args, [ + // F257 #2: --manifest-out is always present; --out carries the caller's path. + assert.deepEqual(call.args.slice(0, 5), [ resolve(root, SCRIPT_REL), '--cat', 'codex', '--profile-dir', resolve(root, 'private/profile'), - '--out', - outPath, ]); + const oIdx = call.args.indexOf('--out'); + assert.equal(call.args[oIdx + 1], outPath, 'passes --out '); + assert.ok(call.args.includes('--manifest-out'), 'also passes --manifest-out'); }); test('compileL0ViaSubprocess fail-closed: unresolvable script path throws', async () => { diff --git a/packages/api/test/li005-ack-liveness-behavior.test.js b/packages/api/test/li005-ack-liveness-behavior.test.js new file mode 100644 index 0000000000..d8f763eab8 --- /dev/null +++ b/packages/api/test/li005-ack-liveness-behavior.test.js @@ -0,0 +1,606 @@ +/** + * LI-005 P2 — A2A ack-liveness end-to-end behavior tests. + * + * Proves the full chain from routeSerial through ack-liveness detection: + * 1. Queue A2A with no exit -> hint + ball.void_ack + * 2. Successful durable trigger -> no hint/void + * 3. Failed trigger (400/error) -> still produces hint/void + * 4. ball.void_ack -> ingest -> projector -> projection state = void + * + * Pattern follows route-serial-phase-h-hint.test.js mock architecture: + * - createCapturingService / createDurableTriggerService for mock agents + * - createMockDeps with ballCustody mock capturing recorded events + * - Real cat roster + routeSerial with a2aTriggerMessageId in options + * + * Sol R7: "R8 至少需证明这四条主路径行为" + */ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { catRegistry } from '@cat-cafe/shared'; + +// Ball-custody for scenario 4 (ingest -> projector -> void) +import { BallCustodyIngest } from '../dist/domains/ball-custody/BallCustodyIngest.js'; +import { BallCustodyProjector } from '../dist/domains/ball-custody/BallCustodyProjector.js'; +import { buildVoidAckEvent } from '../dist/domains/ball-custody/ball-custody-events.js'; + +// --------------------------------------------------------------------------- +// Serialization lock — catRegistry is global; serialize all registry-mutating tests. +// --------------------------------------------------------------------------- +let catRegistryLock = Promise.resolve(); + +async function withCatRegistryLock(fn) { + const previous = catRegistryLock; + let release; + catRegistryLock = new Promise((resolve) => { + release = resolve; + }); + await previous; + try { + return await fn(); + } finally { + release(); + } +} + +// --------------------------------------------------------------------------- +// Mock services +// --------------------------------------------------------------------------- + +/** Simple text-only response — no tools, no routing exit. */ +function createCapturingService(catId, text) { + return { + async *invoke() { + yield { type: 'text', catId, content: text, timestamp: Date.now() }; + yield { type: 'done', catId, timestamp: Date.now() }; + }, + }; +} + +/** No-text response — only tool calls (no text content). Codex R1 P2-1 test. */ +function createToolOnlyService(catId, toolName, toolInput, resultContent, resultStatus) { + const toolUseId = `tu-notext-${Date.now()}`; + return { + async *invoke() { + yield { + type: 'tool_use', + catId, + toolName, + toolInput: toolInput ?? {}, + toolUseId, + id: toolUseId, + timestamp: Date.now(), + }; + yield { + type: 'tool_result', + catId, + content: resultContent, + toolResultStatus: resultStatus, + toolUseId, + timestamp: Date.now(), + }; + yield { type: 'done', catId, timestamp: Date.now() }; + }, + }; +} + +/** Service that calls post_message with targetCats (structured routing). P2-2 test. */ +function createPostMessageService(catId, text, targetCats, resultContent, resultConfirmed) { + const toolUseId = `tu-pm-${Date.now()}`; + return { + async *invoke() { + if (text) yield { type: 'text', catId, content: text, timestamp: Date.now() }; + yield { + type: 'tool_use', + catId, + toolName: 'cat_cafe_post_message', + toolInput: { content: 'review this', targetCats }, + toolUseId, + id: toolUseId, + timestamp: Date.now(), + }; + yield { + type: 'tool_result', + catId, + content: resultConfirmed + ? `{"status":"ok","messageId":"pm-msg-1","threadId":"thr-pm"}` + : `{"error":"delivery_failed","code":500}`, + toolResultStatus: resultConfirmed ? 'ok' : 'error', + toolUseId, + timestamp: Date.now(), + }; + yield { type: 'done', catId, timestamp: Date.now() }; + }, + }; +} + +/** + * Service that calls a durable trigger tool and yields tool_use + tool_result. + * Simulates the bridge all three carriers now provide (Claude print/bg/PTY). + */ +function createDurableTriggerService(catId, text, toolName, toolInput, resultContent, resultStatus) { + const toolUseId = `tu-test-${Date.now()}`; + return { + async *invoke() { + yield { type: 'text', catId, content: text, timestamp: Date.now() }; + yield { + type: 'tool_use', + catId, + toolName, + toolInput: toolInput ?? {}, + toolUseId, + id: toolUseId, + timestamp: Date.now(), + }; + yield { + type: 'tool_result', + catId, + content: resultContent, + toolResultStatus: resultStatus, + toolUseId, + timestamp: Date.now(), + }; + yield { type: 'done', catId, timestamp: Date.now() }; + }, + }; +} + +// --------------------------------------------------------------------------- +// Mock deps (adapted from route-serial-phase-h-hint.test.js) +// --------------------------------------------------------------------------- + +function createMockDeps(services, appendedMessages, recordedBallEvents) { + let counter = 0; + return { + services, + invocationDeps: { + registry: { + create: () => ({ invocationId: `inv-${++counter}`, callbackToken: `tok-${counter}` }), + verify: async () => ({ ok: false, reason: 'unknown_invocation' }), + }, + sessionManager: { + getOrCreate: async () => ({}), + resolveWorkingDirectory: () => '/tmp/test', + }, + threadStore: null, + apiUrl: 'http://127.0.0.1:3004', + }, + messageStore: { + append: async (msg) => { + const stored = { + id: `msg-${++counter}`, + userId: msg.userId ?? '', + catId: msg.catId ?? null, + content: msg.content ?? '', + mentions: msg.mentions ?? [], + timestamp: msg.timestamp ?? 0, + source: msg.source, + }; + appendedMessages.push(stored); + return stored; + }, + getById: () => null, + getRecent: () => [], + getMentionsFor: () => [], + getBefore: () => [], + getByThread: () => [], + getByThreadAfter: () => [], + getByThreadBefore: () => [], + }, + // LI-005: ball-custody mock capturing recorded events + ballCustody: recordedBallEvents ? { record: async (event) => recordedBallEvents.push(event) } : undefined, + }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function loadRealRoster() { + const { loadCatConfig, toAllCatConfigs } = await import('../dist/config/cat-config-loader.js'); + const runtimeConfigs = toAllCatConfigs(loadCatConfig()); + catRegistry.reset(); + for (const [id, config] of Object.entries(runtimeConfigs)) { + catRegistry.register(id, config); + } +} + +/** + * Run routeSerial with queue A2A options (a2aTriggerMessageId). + * Returns appended messages + ball custody events. + */ +async function runA2ARoute(opusService, opts = {}) { + return withCatRegistryLock(async () => { + const original = catRegistry.getAllConfigs(); + await loadRealRoster(); + const appended = []; + const ballEvents = []; + try { + const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); + const codexService = createCapturingService('codex', 'ack'); + const deps = createMockDeps({ opus: opusService, codex: codexService }, appended, ballEvents); + for await (const _ of routeSerial(deps, ['opus'], 'A2A test prompt', 'user1', 'thread-ack-test', { + thinkingMode: 'play', + a2aTriggerMessageId: opts.a2aTriggerMessageId ?? 'trigger-msg-queue-test', + })) { + // drain + } + return { appended, ballEvents }; + } finally { + catRegistry.reset(); + for (const [id, config] of Object.entries(original)) { + catRegistry.register(id, config); + } + } + }); +} + +// --------------------------------------------------------------------------- +// In-memory ball-custody stubs for scenario 4 (same pattern as ingest.test.js) +// --------------------------------------------------------------------------- + +function memLog() { + const events = []; + const seen = new Set(); + return { + append: async (e) => { + if (seen.has(e.sourceEventId)) return { appended: false, sequence: -1 }; + seen.add(e.sourceEventId); + events.push(e); + return { appended: true, sequence: events.length - 1 }; + }, + read: async (sk) => events.filter((e) => e.subjectKey === sk), + listSubjects: async () => [...new Set(events.map((e) => e.subjectKey))], + }; +} + +function memStore() { + const m = new Map(); + return { + get: async (k) => (m.has(k) ? JSON.parse(JSON.stringify(m.get(k))) : null), + save: async (p) => m.set(p.subjectKey, JSON.parse(JSON.stringify(p))), + listSubjectKeys: async () => [...m.keys()], + delete: async (k) => m.delete(k), + }; +} + +// =========================================================================== +// Scenario 1: Queue A2A with no exit -> hint + ball.void_ack +// =========================================================================== + +describe('LI-005 scenario 1: queue A2A no exit -> hint + ball.void_ack', () => { + test('A2A invocation with plain text (no tool, no @exit) emits ack-liveness-hint', async () => { + const opusService = createCapturingService('opus', 'I looked at the code but found nothing actionable.'); + const { appended } = await runA2ARoute(opusService); + + const hint = appended.find((m) => m.source?.connector === 'ack-liveness-hint'); + assert.ok(hint, 'must emit ack-liveness-hint when A2A invocation has no exit'); + assert.equal(hint.userId, 'system'); + assert.equal(hint.catId, null); + assert.match(hint.content, /接球提醒/); + assert.match(hint.content, /hold_ball|触发器/); + assert.equal(hint.source.icon, '🏓'); + assert.equal(hint.source.meta.presentation, 'system_notice'); + assert.equal(hint.source.meta.noticeTone, 'warning'); + }); + + test('A2A invocation with no exit emits ball.void_ack event', async () => { + const opusService = createCapturingService('opus', 'Done reviewing, nothing to do.'); + const { ballEvents } = await runA2ARoute(opusService); + + const voidAck = ballEvents.find((e) => e.kind === 'ball.void_ack'); + assert.ok(voidAck, 'must emit ball.void_ack when A2A invocation has no exit'); + assert.match(voidAck.subjectKey, /^ball:thread:/); + assert.equal(voidAck.classification, 'state-changing'); + }); +}); + +// =========================================================================== +// Scenario 2: Successful durable trigger -> no hint/void +// =========================================================================== + +describe('LI-005 scenario 2: successful durable trigger -> no hint/void', () => { + test('hold_ball with ok result suppresses ack-liveness-hint', async () => { + const opusService = createDurableTriggerService( + 'opus', + 'Holding ball while waiting for CI.', + 'cat_cafe_hold_ball', + { wakeAfterMs: 300000 }, + '{"status":"ok","held":true,"taskId":"hold-42"}', + 'ok', + ); + const { appended, ballEvents } = await runA2ARoute(opusService); + + const hint = appended.find((m) => m.source?.connector === 'ack-liveness-hint'); + assert.equal(hint, undefined, 'successful hold_ball must suppress ack-liveness-hint'); + + const voidAck = ballEvents.find((e) => e.kind === 'ball.void_ack'); + assert.equal(voidAck, undefined, 'successful hold_ball must not emit ball.void_ack'); + }); + + test('register_scheduled_task with success:true suppresses hint', async () => { + const opusService = createDurableTriggerService( + 'opus', + 'Scheduling follow-up check.', + 'cat_cafe_register_scheduled_task', + { cronExpression: '0 */6 * * *' }, + '{"success":true,"task":{"id":"sched-1"}}', + 'ok', + ); + const { appended, ballEvents } = await runA2ARoute(opusService); + + const hint = appended.find((m) => m.source?.connector === 'ack-liveness-hint'); + assert.equal(hint, undefined, 'successful register_scheduled_task must suppress hint'); + + const voidAck = ballEvents.find((e) => e.kind === 'ball.void_ack'); + assert.equal(voidAck, undefined, 'successful register_scheduled_task must not emit void_ack'); + }); + + test('register_pr_tracking with status:ok suppresses hint', async () => { + const opusService = createDurableTriggerService( + 'opus', + 'Tracking PR #123.', + 'cat_cafe_register_pr_tracking', + { prUrl: 'https://github.com/org/repo/pull/123' }, + '{"status":"ok","threadId":"thr-pr","task":{"id":"pr-1"}}', + 'ok', + ); + const { appended, ballEvents } = await runA2ARoute(opusService); + + const hint = appended.find((m) => m.source?.connector === 'ack-liveness-hint'); + assert.equal(hint, undefined, 'successful PR tracking must suppress hint'); + assert.equal( + ballEvents.find((e) => e.kind === 'ball.void_ack'), + undefined, + 'no void_ack on successful PR tracking', + ); + }); +}); + +// =========================================================================== +// Scenario 3: Failed trigger -> still produces hint/void +// =========================================================================== + +describe('LI-005 scenario 3: failed trigger -> hint + ball.void_ack', () => { + test('hold_ball with error status still emits ack-liveness-hint', async () => { + const opusService = createDurableTriggerService( + 'opus', + 'Trying to hold ball.', + 'cat_cafe_hold_ball', + { wakeAfterMs: 300000 }, + 'Rate limit exceeded', + 'error', + ); + const { appended, ballEvents } = await runA2ARoute(opusService); + + const hint = appended.find((m) => m.source?.connector === 'ack-liveness-hint'); + assert.ok(hint, 'failed hold_ball must still emit ack-liveness-hint'); + assert.match(hint.content, /接球提醒/); + + const voidAck = ballEvents.find((e) => e.kind === 'ball.void_ack'); + assert.ok(voidAck, 'failed hold_ball must emit ball.void_ack'); + }); + + test('hold_ball with unknown/no toolResultStatus and error body still emits hint', async () => { + // Simulates provider returning tool_result without explicit status + + // body containing error markers (Level 2 parse fails closed). + const opusService = createDurableTriggerService( + 'opus', + 'Holding ball.', + 'cat_cafe_hold_ball', + { wakeAfterMs: 60000 }, + '{"error":"permission_denied","code":403}', + 'unknown', + ); + const { appended, ballEvents } = await runA2ARoute(opusService); + + const hint = appended.find((m) => m.source?.connector === 'ack-liveness-hint'); + assert.ok(hint, 'error-body hold_ball must emit ack-liveness-hint (fail-closed)'); + + const voidAck = ballEvents.find((e) => e.kind === 'ball.void_ack'); + assert.ok(voidAck, 'error-body hold_ball must emit ball.void_ack'); + }); + + test('non-durable tool (create_task) does not suppress hint', async () => { + // create_task is NOT a durable trigger — it creates a panel item but + // has no wake mechanism. The hint should still fire. + const opusService = createDurableTriggerService( + 'opus', + 'Creating a task to track this.', + 'cat_cafe_create_task', + { title: 'Follow up on review' }, + '{"status":"ok","taskId":"task-99"}', + 'ok', + ); + const { appended, ballEvents } = await runA2ARoute(opusService); + + const hint = appended.find((m) => m.source?.connector === 'ack-liveness-hint'); + assert.ok(hint, 'create_task is not a durable trigger; hint must fire'); + + const voidAck = ballEvents.find((e) => e.kind === 'ball.void_ack'); + assert.ok(voidAck, 'create_task does not prevent void_ack'); + }); +}); + +// =========================================================================== +// Scenario 4: ball.void_ack -> ingest -> projector -> projection state = void +// =========================================================================== + +describe('LI-005 scenario 4: ball.void_ack ingest -> projection = void', () => { + test('void_ack event transitions projection from active to void', async () => { + const log = memLog(); + const store = memStore(); + const proj = new BallCustodyProjector(log, store); + const ingest = new BallCustodyIngest(log, proj); + + const threadId = 'thr-void-ack-test'; + const subjectKey = `ball:thread:${threadId}`; + + // First, establish an active projection via ball.handed + const { buildHandedEvent } = await import('../dist/domains/ball-custody/ball-custody-events.js'); + const handedEvent = buildHandedEvent({ + fromCatId: 'codex', + toCatId: 'opus', + threadId, + messageId: 'msg-handed-1', + at: 1000, + }); + await ingest.record(handedEvent); + + // Verify active state + const beforeProjection = await store.get(subjectKey); + assert.ok(beforeProjection, 'projection must exist after ball.handed'); + assert.equal(beforeProjection.state, 'active', 'state must be active after ball.handed'); + + // Now emit ball.void_ack + const voidAckEvent = buildVoidAckEvent({ + threadId, + messageId: 'msg-void-ack-1', + a2aTriggerMessageId: 'trigger-msg-test', + at: 2000, + }); + await ingest.record(voidAckEvent); + + // Verify void state + const afterProjection = await store.get(subjectKey); + assert.ok(afterProjection, 'projection must exist after void_ack'); + assert.equal(afterProjection.state, 'void', 'state must be void after ball.void_ack'); + assert.equal(afterProjection.appliedEventCount, 2, 'two events applied (handed + void_ack)'); + }); + + test('void_ack event builds correct sourceEventId and payload', () => { + const event = buildVoidAckEvent({ + threadId: 'thr-src-test', + messageId: 'msg-src-1', + a2aTriggerMessageId: 'trigger-123', + at: 3000, + }); + + assert.equal(event.kind, 'ball.void_ack'); + assert.equal(event.sourceEventId, 'route:msg-src-1:void_ack'); + assert.equal(event.subjectKey, 'ball:thread:thr-src-test'); + assert.equal(event.classification, 'state-changing'); + assert.equal(event.at, 3000); + assert.deepStrictEqual(event.payload, { a2aTriggerMessageId: 'trigger-123' }); + }); + + test('void_ack without a2aTriggerMessageId omits payload field', () => { + const event = buildVoidAckEvent({ + threadId: 'thr-no-trigger', + messageId: 'msg-no-trigger', + at: 4000, + }); + + assert.equal(event.kind, 'ball.void_ack'); + assert.deepStrictEqual(event.payload, {}); + }); + + test('void_ack from new state (no prior handed) also transitions to void', async () => { + const log = memLog(); + const store = memStore(); + const proj = new BallCustodyProjector(log, store); + const ingest = new BallCustodyIngest(log, proj); + + const threadId = 'thr-void-from-new'; + const subjectKey = `ball:thread:${threadId}`; + + // Direct void_ack without prior state (inline serial A2A first touch) + const voidAckEvent = buildVoidAckEvent({ + threadId, + messageId: 'msg-direct-void', + at: 5000, + }); + await ingest.record(voidAckEvent); + + const projection = await store.get(subjectKey); + assert.ok(projection, 'projection must exist'); + // new -> void transition (ball.void_ack from: set('new', ...)) + assert.equal(projection.state, 'void', 'new -> void via ball.void_ack'); + }); +}); + +// =========================================================================== +// Scenario 5: No-text A2A turns — Codex R1 P2-1 fix +// =========================================================================== + +describe('LI-005 scenario 5: no-text A2A turns (Codex P2-1)', () => { + test('tool-only A2A invocation (no text, non-durable tool) emits ack-liveness-hint', async () => { + // Cat responds with only a tool call (create_task — not durable), no text. + // Before the fix, this bypassed ack-liveness entirely. + const opusService = createToolOnlyService( + 'opus', + 'cat_cafe_create_task', + { title: 'Track follow-up' }, + '{"status":"ok","taskId":"task-77"}', + 'ok', + ); + const { appended, ballEvents } = await runA2ARoute(opusService); + + const hint = appended.find((m) => m.source?.connector === 'ack-liveness-hint'); + assert.ok(hint, 'no-text A2A with non-durable tool must emit ack-liveness-hint'); + assert.match(hint.content, /接球提醒/); + + const voidAck = ballEvents.find((e) => e.kind === 'ball.void_ack'); + assert.ok(voidAck, 'no-text A2A with non-durable tool must emit ball.void_ack'); + }); + + test('tool-only A2A with successful hold_ball suppresses hint', async () => { + // No text, but cat called hold_ball successfully — ball is alive. + const opusService = createToolOnlyService( + 'opus', + 'cat_cafe_hold_ball', + { wakeAfterMs: 60000 }, + '{"status":"ok","held":true}', + 'ok', + ); + const { appended, ballEvents } = await runA2ARoute(opusService); + + const hint = appended.find((m) => m.source?.connector === 'ack-liveness-hint'); + assert.equal(hint, undefined, 'no-text with successful hold_ball must suppress hint'); + + const voidAck = ballEvents.find((e) => e.kind === 'ball.void_ack'); + assert.equal(voidAck, undefined, 'no-text with successful hold_ball must not emit void_ack'); + }); +}); + +// =========================================================================== +// Scenario 6: Confirmed vs unconfirmed structured routing — Codex R1 P2-2 fix +// =========================================================================== + +describe('LI-005 scenario 6: confirmed structured routing (Codex P2-2)', () => { + test('failed post_message (unconfirmed) does NOT suppress hint', async () => { + // Cat calls post_message(targetCats: ['codex']) but it fails. + // Before the fix, structuredTargetCats still had ['codex'] → hint suppressed. + const opusService = createPostMessageService( + 'opus', + 'Sending review request.', + ['codex'], + '{"error":"delivery_failed"}', + false, + ); + const { appended, ballEvents } = await runA2ARoute(opusService); + + const hint = appended.find((m) => m.source?.connector === 'ack-liveness-hint'); + assert.ok(hint, 'failed post_message must NOT suppress ack-liveness-hint'); + + const voidAck = ballEvents.find((e) => e.kind === 'ball.void_ack'); + assert.ok(voidAck, 'failed post_message must emit ball.void_ack'); + }); + + test('successful post_message (confirmed) suppresses hint', async () => { + // Cat calls post_message(targetCats: ['codex']) and it succeeds. + const opusService = createPostMessageService( + 'opus', + 'Sending review request.', + ['codex'], + '{"status":"ok","messageId":"pm-1","threadId":"thr-pm"}', + true, + ); + const { appended, ballEvents } = await runA2ARoute(opusService); + + const hint = appended.find((m) => m.source?.connector === 'ack-liveness-hint'); + assert.equal(hint, undefined, 'successful post_message must suppress hint'); + + const voidAck = ballEvents.find((e) => e.kind === 'ball.void_ack'); + assert.equal(voidAck, undefined, 'successful post_message must not emit void_ack'); + }); +}); diff --git a/packages/api/test/magic-word-metric.test.js b/packages/api/test/magic-word-metric.test.js new file mode 100644 index 0000000000..8739a68818 --- /dev/null +++ b/packages/api/test/magic-word-metric.test.js @@ -0,0 +1,634 @@ +/** + * F257 V1 — magic word 词面出现数 metric tests (T-B §3.5 contract). + * + * Semantics single source of truth: F257 redesign doc T-B (§3.5). + * Event Memory = single source of truth (in-memory SQLite here); Redis carries + * the message authority + watermark (isolated redis suite pattern). + */ + +import assert from 'node:assert/strict'; +import { after, before, beforeEach, describe, it } from 'node:test'; +import Fastify from 'fastify'; +import { + assertRedisIsolationOrThrow, + cleanupClientKeyspace, + redisIsolationSkipReason, +} from './helpers/redis-test-helpers.js'; + +const REDIS_URL = process.env.REDIS_URL; +const OWNER = 'owner-f257-mw'; +// Per-file keyPrefix: hard keyspace isolation from concurrently running test +// files (cleanupClientKeyspace precedent — cross-file `msg:*` wildcard cleanup +// races with the strict missing-hash contract otherwise). +const TEST_KEY_PREFIX = 'cat-cafe:f257mw:'; + +describe('F257 V1: MagicWordMetricService (T-B)', { skip: redisIsolationSkipReason(REDIS_URL) }, () => { + let redis; + let store; + let eventMemory; + let service; + let connected = false; + + before(async () => { + assertRedisIsolationOrThrow(REDIS_URL, 'MagicWordMetricService'); + const redisModule = await import('@cat-cafe/shared/utils'); + redis = redisModule.createRedisClient({ url: REDIS_URL, keyPrefix: TEST_KEY_PREFIX }); + try { + await redis.ping(); + connected = true; + } catch { + await redis.quit().catch(() => {}); + return; + } + }); + + after(async () => { + if (redis && connected) { + await cleanupClientKeyspace(redis); + await redis.quit(); + } + }); + + beforeEach(async (t) => { + if (!connected) return t.skip('Redis not connected'); + await cleanupClientKeyspace(redis); + const emModule = await import('../dist/domains/memory/EventMemoryStore.js'); + eventMemory = new emModule.EventMemoryStore(':memory:'); + await eventMemory.initialize(); + const storeModule = await import('../dist/domains/cats/services/stores/redis/RedisMessageStore.js'); + store = new storeModule.RedisMessageStore(redis, { + onBeforeHardDelete: (msg) => eventMemory.deleteByCoord(msg.threadId, msg.id), + onBeforeDeleteByThread: (threadId) => eventMemory.deleteByThread(threadId), + }); + const svcModule = await import('../dist/infrastructure/harness-eval/task-outcome/magic-word-metric.js'); + service = new svcModule.MagicWordMetricService({ redis, eventMemoryStore: eventMemory }); + }); + + async function appendUserMessage(content, timestamp, extra = {}) { + return store.append({ + userId: OWNER, + catId: null, + content, + mentions: [], + timestamp, + threadId: 'th-f257-mw', + provenance: { author: 'user', routed: false, observation: 'original' }, // sol R3 P1-2: author axis selects the cohort + ...extra, + }); + } + + it('sol R4 P1-1c: malformed provenance -> unmeasurable window; absent legacy -> out of cohort only', async () => { + const now = Date.now(); + const bad = await appendUserMessage('这个方案绕路了', now - 500); + await appendUserMessage('正常消息 第一性原理', now - 400); + // storage fault repro (sol R4): corrupt the persisted declaration + await redis.hset(`msg:${bad.id}`, 'provenance', '{"author":"user"'); + + const rec = await service.reconcileWindow(OWNER, now - 1000, now); + assert.equal(rec.ok, false, 'corrupt declaration is a collection gap, not a smaller cohort'); + const counts = await service.computeWordCounts(OWNER, now - 1000, now); + assert.equal(counts.unmeasurable, true, 'exact metric must refuse to report over a corrupt window'); + + // absent (legacy pre-contract) is a DIFFERENT fact: measurable, message out of cohort + await redis.hdel(`msg:${bad.id}`, 'provenance'); + const rec2 = await service.reconcileWindow(OWNER, now - 1000, now); + assert.equal(rec2.ok, true); + assert.equal(rec2.scanned, 1, 'legacy message honestly out of cohort'); + }); + + it('R5: author/catId contradictions and empty provenance make the window unmeasurable', async () => { + const now = Date.now(); + const bad = await appendUserMessage('这个方案绕路了', now - 500); + await redis.hset(`msg:${bad.id}`, { + catId: 'opus', + provenance: JSON.stringify({ author: 'user', routed: false, observation: 'original' }), + }); + const contradicted = await service.computeWordCounts(OWNER, now - 1000, now); + assert.equal(contradicted.unmeasurable, true, 'cat text cannot masquerade as an operator observation'); + + await redis.hset(`msg:${bad.id}`, { catId: '', provenance: '' }); + const empty = await service.computeWordCounts(OWNER, now - 1000, now); + assert.equal(empty.unmeasurable, true, 'present-but-empty provenance is storage corruption, not legacy absence'); + }); + + it('R6: missing/non-numeric timestamp and missing content fail closed', async () => { + const now = Date.now(); + const msg = await appendUserMessage('这个方案绕路了', now - 500); + + await redis.hdel(`msg:${msg.id}`, 'timestamp'); + assert.equal((await service.computeWordCounts(OWNER, now - 1000, now)).unmeasurable, true); + + await redis.hset(`msg:${msg.id}`, 'timestamp', 'not-a-number'); + assert.equal((await service.computeWordCounts(OWNER, now - 1000, now)).unmeasurable, true); + + await redis.hset(`msg:${msg.id}`, 'timestamp', String(now - 500)); + await redis.hdel(`msg:${msg.id}`, 'content'); + assert.equal((await service.computeWordCounts(OWNER, now - 1000, now)).unmeasurable, true); + + await redis.hset(`msg:${msg.id}`, 'content', '这个方案绕路了'); + await redis.hdel(`msg:${msg.id}`, 'catId'); + assert.equal( + (await service.computeWordCounts(OWNER, now - 1000, now)).unmeasurable, + true, + 'catId is nullable in meaning but the persisted field itself is required', + ); + }); + + it('R6/R7: hash id/owner/effective order must match the owner timeline coordinates', async () => { + const now = Date.now(); + const msg = await appendUserMessage('这个方案绕路了', now - 500); + + await redis.hset(`msg:${msg.id}`, 'id', 'different-message-id'); + assert.equal((await service.computeWordCounts(OWNER, now - 1000, now)).unmeasurable, true); + + await redis.hset(`msg:${msg.id}`, { id: msg.id, userId: 'different-owner' }); + assert.equal((await service.computeWordCounts(OWNER, now - 1000, now)).unmeasurable, true); + + await redis.hset(`msg:${msg.id}`, 'userId', OWNER); + await redis.zadd(`msg:user:${OWNER}`, String(now - 400), msg.id); + assert.equal( + (await service.computeWordCounts(OWNER, now - 1000, now)).unmeasurable, + true, + 'timeline score and authority timestamp disagreement is corruption', + ); + + await redis.zadd(`msg:user:${OWNER}`, String(now - 500), msg.id); + await redis.hset(`msg:${msg.id}`, 'deliveredAt', 'not-a-number'); + assert.equal( + (await service.computeWordCounts(OWNER, now - 1000, now)).unmeasurable, + true, + 'present-but-malformed deliveredAt is corruption, not a fallback to timestamp', + ); + }); + + it('R6: malformed mentions/source/routingFact payloads fail closed', async () => { + const now = Date.now(); + const msg = await appendUserMessage('这个方案绕路了', now - 500); + + await redis.hset(`msg:${msg.id}`, 'mentions', '{'); + assert.equal((await service.computeWordCounts(OWNER, now - 1000, now)).unmeasurable, true); + + await redis.hset(`msg:${msg.id}`, 'mentions', '[]'); + await redis.hset(`msg:${msg.id}`, 'source', '{'); + assert.equal((await service.computeWordCounts(OWNER, now - 1000, now)).unmeasurable, true); + + await redis.hdel(`msg:${msg.id}`, 'source'); + await redis.hset(`msg:${msg.id}`, { + routingFact: '', + provenance: JSON.stringify({ author: 'user', routed: true, observation: 'original' }), + }); + assert.equal((await service.computeWordCounts(OWNER, now - 1000, now)).unmeasurable, true); + + await redis.hset(`msg:${msg.id}`, 'routingFact', '{'); + assert.equal((await service.computeWordCounts(OWNER, now - 1000, now)).unmeasurable, true); + }); + + it('R6: external connector words are not authenticated-operator metric observations', async () => { + const now = Date.now(); + await store.append({ + userId: OWNER, + catId: null, + content: '这个流程绕路了', + mentions: [], + timestamp: now - 500, + threadId: 'th-f257-mw', + source: { connector: 'telegram', label: 'Telegram', icon: 'telegram' }, + provenance: { author: 'external_user', routed: false, observation: 'original' }, + }); + + const result = await service.computeWordCounts(OWNER, now - 1000, now); + assert.equal(result.unmeasurable, false); + assert.equal(result.reconcile.scanned, 0); + assert.deepEqual(result.counts, {}); + }); + + it('R6: branch edit is a new current observation in the branch→metric path', async () => { + const now = Date.now(); + const sourceThreadId = 'th-f257-mw-old'; + const source = await store.append({ + userId: OWNER, + catId: null, + content: '旧消息', + mentions: [], + timestamp: now - 86_400_000, + threadId: sourceThreadId, + provenance: { author: 'user', routed: false, observation: 'original' }, + }); + const threads = new Map([ + [ + sourceThreadId, + { + id: sourceThreadId, + title: '旧对话', + projectPath: 'default', + createdBy: OWNER, + participants: [], + createdAt: now - 86_400_000, + lastActiveAt: now - 86_400_000, + }, + ], + ]); + let branchSeq = 0; + const threadStore = { + create(userId, title, projectPath) { + const thread = { + id: `th-f257-mw-branch-${++branchSeq}`, + title, + projectPath, + createdBy: userId, + participants: [], + createdAt: Date.now(), + lastActiveAt: Date.now(), + }; + threads.set(thread.id, thread); + return thread; + }, + get: (id) => threads.get(id) ?? null, + addParticipants() {}, + delete: (id) => threads.delete(id), + }; + const socketManager = { broadcastAgentMessage() {}, broadcastToRoom() {} }; + const { threadBranchRoutes } = await import('../dist/routes/thread-branch.js'); + const app = Fastify(); + await app.register(threadBranchRoutes, { messageStore: store, threadStore, socketManager }); + await app.ready(); + try { + const requestStartedAt = Date.now(); + const response = await app.inject({ + method: 'POST', + url: `/api/threads/${sourceThreadId}/branch`, + payload: { fromMessageId: source.id, editedContent: '这个方案绕路了', userId: OWNER }, + }); + assert.equal(response.statusCode, 201, response.body); + + const result = await service.computeWordCounts(OWNER, requestStartedAt - 1, Date.now() + 1); + assert.equal(result.unmeasurable, false); + assert.deepEqual(result.counts, { 绕路了: 1 }); + assert.equal(result.reconcile.scanned, 1); + } finally { + await app.close(); + } + }); + + it('R7: queued magic word is measured in its delivery-time window', async () => { + const deliveredAt = Date.now(); + const sentAt = deliveredAt - 60_000; + const msg = await appendUserMessage('这个方案绕路了', sentAt, { deliveryStatus: 'queued' }); + + // Real queued path detects before delivery. Its event timestamp therefore + // predates the eventual delivery-time window and must not be used to prune + // the coordinate join. + eventMemory.markEvent( + { + type: '绕路了', + trigger: 'human_brake', + cat: 'unknown', + threadId: msg.threadId, + messageId: msg.id, + timestamp: sentAt, + summary: '这个方案绕路了', + cognitiveTransition: 'user_brake', + relatedHarness: null, + confidence: 'high', + }, + OWNER, + ); + + await store.markDelivered(msg.id, deliveredAt); + + const result = await service.computeWordCounts(OWNER, deliveredAt - 100, deliveredAt + 100); + assert.equal(result.unmeasurable, false); + assert.deepEqual(result.counts, { 绕路了: 1 }); + assert.equal(result.reconcile.scanned, 1); + assert.equal(result.reconcile.backfilled, 0, 'the pre-delivery live event is joined, not duplicated'); + }); + + it('R7: delivered magic word keeps its effective score when reassigned to a new owner', async () => { + const deliveredAt = Date.now(); + const nextOwner = `${OWNER}-reassigned`; + const msg = await appendUserMessage('这里要第一性原理', deliveredAt - 60_000, { deliveryStatus: 'queued' }); + await store.markDelivered(msg.id, deliveredAt); + + await store.reassignUserId(msg.id, nextOwner); + + const result = await service.computeWordCounts(nextOwner, deliveredAt - 100, deliveredAt + 100); + assert.equal(result.unmeasurable, false); + assert.deepEqual(result.counts, { 第一性原理: 1 }); + assert.equal(result.reconcile.scanned, 1); + const [event] = eventMemory.getByCoord(msg.threadId, msg.id, nextOwner); + assert.equal(event.timestamp, deliveredAt, 'reconcile backfill uses the effective delivery coordinate'); + }); + + it('R8: soft delete excludes the observation until restore without destroying recoverable events', async () => { + const now = Date.now(); + const msg = await appendUserMessage('这里要第一性原理', now - 500); + + const before = await service.computeWordCounts(OWNER, now - 1000, now); + assert.equal(before.unmeasurable, false); + assert.deepEqual(before.counts, { 第一性原理: 1 }); + + await store.softDelete(msg.id, OWNER); + const deleted = await service.computeWordCounts(OWNER, now - 1000, now); + assert.equal(deleted.unmeasurable, false); + assert.equal(deleted.reconcile.scanned, 0); + assert.deepEqual(deleted.counts, {}); + assert.equal(eventMemory.getByCoord(msg.threadId, msg.id, OWNER).length, 1, 'soft delete remains restorable'); + + await store.restore(msg.id); + const restored = await service.computeWordCounts(OWNER, now - 1000, now); + assert.equal(restored.unmeasurable, false); + assert.deepEqual(restored.counts, { 第一性原理: 1 }); + }); + + it('R8: identical hard-delete tombstones converge and scrub live Event Memory content', async () => { + const now = Date.now(); + const liveHit = await appendUserMessage('这个方案绕路了', now - 600); + const missedHit = await appendUserMessage('这个方案绕路了', now - 500); + eventMemory.markEvent( + { + type: '绕路了', + trigger: 'human_brake', + cat: 'unknown', + threadId: liveHit.threadId, + messageId: liveHit.id, + timestamp: liveHit.timestamp, + summary: liveHit.content, + cognitiveTransition: 'user_brake', + relatedHarness: null, + confidence: 'high', + }, + OWNER, + ); + + await store.hardDelete(liveHit.id, OWNER); + await store.hardDelete(missedHit.id, OWNER); + + const result = await service.computeWordCounts(OWNER, now - 1000, now); + assert.equal(result.unmeasurable, false); + assert.equal(result.reconcile.scanned, 0); + assert.deepEqual(result.counts, {}); + assert.equal(result.total, 0); + assert.deepEqual(eventMemory.getByCoord(liveHit.threadId, liveHit.id), [], 'hard delete scrubs excerpt data'); + }); + + it('R9: a stale metric snapshot cannot reinsert an excerpt after hard delete linearizes', async () => { + const now = Date.now(); + const msg = await appendUserMessage('这个方案是脚手架', now - 500); + const originalRead = service.readWindowMessages.bind(service); + let releaseSnapshot; + let announceSnapshot; + const snapshotReady = new Promise((resolve) => { + announceSnapshot = resolve; + }); + const snapshotRelease = new Promise((resolve) => { + releaseSnapshot = resolve; + }); + service.readWindowMessages = async (...args) => { + const snapshot = await originalRead(...args); + announceSnapshot(); + await snapshotRelease; + return snapshot; + }; + + const racedCompute = service.computeWordCounts(OWNER, now - 1000, now); + await snapshotReady; + await store.hardDelete(msg.id, OWNER); + releaseSnapshot(); + + const raced = await racedCompute; + assert.equal(raced.unmeasurable, true, 'stale snapshot must fail closed at the durable write fence'); + assert.deepEqual(eventMemory.getByCoord(msg.threadId, msg.id), [], 'no full-text excerpt may reappear'); + + service.readWindowMessages = originalRead; + const settled = await service.computeWordCounts(OWNER, now - 1000, now); + assert.equal(settled.unmeasurable, false); + assert.equal(settled.total, 0); + }); + + it('R8: physical thread deletion removes message coordinates and Event Memory without a collection gap', async () => { + const now = Date.now(); + const msg = await appendUserMessage('这是脚手架', now - 500); + await service.computeWordCounts(OWNER, now - 1000, now); + assert.equal(eventMemory.getByCoord(msg.threadId, msg.id, OWNER).length, 1); + + assert.equal(await store.deleteByThread(msg.threadId), 1); + + const result = await service.computeWordCounts(OWNER, now - 1000, now); + assert.equal(result.unmeasurable, false); + assert.equal(result.reconcile.scanned, 0); + assert.deepEqual(result.counts, {}); + assert.deepEqual(eventMemory.getByCoord(msg.threadId, msg.id), []); + }); + + it('R8: malformed delete markers and token-bearing tombstones fail closed', async () => { + const now = Date.now(); + const msg = await appendUserMessage('这个方案绕路了', now - 500); + + await redis.hset(`msg:${msg.id}`, 'deletedAt', String(now)); + assert.equal( + (await service.computeWordCounts(OWNER, now - 1000, now)).unmeasurable, + true, + 'deletedAt without deletedBy is not a valid soft-delete state', + ); + + await redis.hset(`msg:${msg.id}`, { deletedBy: OWNER, _tombstone: 'broken' }); + assert.equal((await service.computeWordCounts(OWNER, now - 1000, now)).unmeasurable, true); + + await redis.hset(`msg:${msg.id}`, { _tombstone: '1', content: '', mentions: '[]' }); + assert.equal( + (await service.computeWordCounts(OWNER, now - 1000, now)).unmeasurable, + true, + 'hard-delete skeleton retaining F257 provenance is corrupt, not silently excluded', + ); + }); + + it('R5: derived branch history does not create a second magic-word observation', async () => { + const now = Date.now(); + const original = await appendUserMessage('这个方案绕路了', now - 600, { + provenance: { author: 'user', routed: false, observation: 'original' }, + }); + await appendUserMessage('这个方案绕路了', now - 500, { + provenance: { + author: 'user', + routed: false, + observation: 'derived', + sourceRef: `message:${original.id}`, + }, + threadId: 'th-f257-mw-branch', + }); + + const result = await service.computeWordCounts(OWNER, now - 1000, now); + assert.equal(result.unmeasurable, false); + assert.equal(result.reconcile.scanned, 1, 'only the original user observation is scanned'); + assert.deepEqual(result.counts, { 绕路了: 1 }); + }); + + it('reconcile backfills hits the live path missed, with message timestamps (idempotent)', async () => { + const now = Date.now(); + const msg = await appendUserMessage('这个方案绕路了,回到主线', now - 500); + await appendUserMessage('普通消息没有词', now - 400); + + const first = await service.reconcileWindow(OWNER, now - 1000, now); + assert.equal(first.ok, true); + assert.equal(first.scanned, 2); + assert.equal(first.backfilled, 1); + + const events = eventMemory.listEvents({ ownerUserId: OWNER }); + assert.equal(events.length, 1); + assert.equal(events[0].type, '绕路了'); + assert.equal(events[0].messageId, msg.id); + assert.equal(events[0].timestamp, now - 500, 'event carries the message timestamp, not scan time'); + + const second = await service.reconcileWindow(OWNER, now - 1000, now); + assert.equal(second.backfilled, 0, 'idempotent — markEvent dedups on (owner,thread,msg,word)'); + + const watermark = await service.getWatermark(OWNER); + assert.equal(watermark, now); + }); + + it('computeWordCounts counts unique (message, word) hits per word', async () => { + const now = Date.now(); + // same word twice in ONE message → 1 unique hit + await appendUserMessage('绕路了绕路了,你这绕路了', now - 900); + // same word in a SECOND message → +1 + await appendUserMessage('又绕路了', now - 800); + // different word → its own count + await appendUserMessage('这是脚手架吧', now - 700); + + const result = await service.computeWordCounts(OWNER, now - 1000, now); + assert.equal(result.unmeasurable, false); + assert.deepEqual(result.counts, { 绕路了: 2, 脚手架: 1 }); + assert.equal(result.total, 3); + assert.equal(result.reconcile.backfilled, 3); + }); + + it('cat-authored messages are out of cohort', async () => { + const now = Date.now(); + // real stream shape: routed lane + cat author — excluded by authorship + await store.append({ + userId: OWNER, + catId: 'opus', + content: '用户之前说绕路了,我调整了方向', + mentions: [], + timestamp: now - 500, + threadId: 'th-f257-mw', + provenance: { author: 'cat', routed: false, observation: 'original' }, + }); + const result = await service.computeWordCounts(OWNER, now - 1000, now); + assert.equal(result.unmeasurable, false); + assert.deepEqual(result.counts, {}); + assert.equal(result.reconcile.scanned, 0, 'cat messages are not scanned'); + }); + + it('non-routed real user messages (game lane) ARE counted (sol R3 P1-2 repro)', async () => { + const now = Date.now(); + // sol repro: game-lane user message — real operator words, no routing parser ran + await store.append({ + userId: OWNER, + catId: null, + content: '这个流程绕路了', + mentions: [], + timestamp: now - 500, + threadId: 'th-f257-mw', + provenance: { author: 'user', routed: false, observation: 'original' }, + }); + const result = await service.computeWordCounts(OWNER, now - 1000, now); + assert.equal(result.unmeasurable, false); + assert.equal(result.reconcile.scanned, 1, 'author axis selects it regardless of routing'); + assert.deepEqual(result.counts, { 绕路了: 1 }); + }); + + it('surface messages quoting a magic word are not operator hits (sol R2 P1-1 repro)', async () => { + const now = Date.now(); + // sol repro: system relay message — catId null but NOT a routed lane + await store.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, + userId: OWNER, + catId: null, + content: '系统转述:用户之前说绕路了', + mentions: [], + timestamp: now - 500, + threadId: 'th-f257-mw', + source: { connector: 'relay', label: '转述', icon: '📣' }, + }); + const result = await service.computeWordCounts(OWNER, now - 1000, now); + assert.equal(result.unmeasurable, false); + assert.equal(result.reconcile.scanned, 0, 'surface message is not scanned'); + assert.deepEqual(result.counts, {}, 'no operator hit from a system relay'); + }); + + it('live-written events are not double-counted by reconcile', async () => { + const now = Date.now(); + const msg = await appendUserMessage('第一性原理 砍掉脚手架', now - 500); + // simulate the live path having already written one of the two hits + eventMemory.markEvent( + { + type: '第一性原理', + trigger: 'human_brake', + cat: 'unknown', + threadId: 'th-f257-mw', + messageId: msg.id, + timestamp: now - 500, + summary: '第一性原理 砍掉脚手架', + cognitiveTransition: 'user_brake', + relatedHarness: null, + confidence: 'high', + }, + OWNER, + ); + + const result = await service.computeWordCounts(OWNER, now - 1000, now); + assert.equal(result.unmeasurable, false); + assert.deepEqual(result.counts, { 第一性原理: 1, 脚手架: 1 }); + assert.equal(result.reconcile.backfilled, 1, 'only the missed hit is backfilled'); + }); + + it('window boundaries exclude out-of-window hits', async () => { + const now = Date.now(); + await appendUserMessage('绕路了', now - 5000); + await appendUserMessage('脚手架', now - 500); + const result = await service.computeWordCounts(OWNER, now - 1000, now); + assert.equal(result.unmeasurable, false); + assert.deepEqual(result.counts, { 脚手架: 1 }); + }); + + it('live event with late detection timestamp still counts via message-coordinate join (sol R1 P1-4)', async () => { + const base = Date.now() - 100_000; + // sol repro: message at t, live event recorded at t+1000 (detection time), + // window ends between the two — the hit belongs to the window的 message. + const msg = await appendUserMessage('这就绕路了', base + 1000); + eventMemory.markEvent( + { + type: '绕路了', + trigger: 'human_brake', + cat: 'unknown', + threadId: 'th-f257-mw', + messageId: msg.id, + timestamp: base + 2000, // live path stamps detection time, not message time + summary: '这就绕路了', + cognitiveTransition: 'user_brake', + relatedHarness: null, + confidence: 'high', + }, + OWNER, + ); + + const result = await service.computeWordCounts(OWNER, base, base + 1500); + assert.equal(result.unmeasurable, false); + assert.deepEqual(result.counts, { 绕路了: 1 }, 'join by message coordinates, not event timestamp'); + assert.equal(result.reconcile.backfilled, 0, 'dedup key already claimed by the live event'); + }); + + it('an indexed message with a missing hash forces unmeasurable (sol R1 P1-4)', async () => { + const now = Date.now(); + const msg = await appendUserMessage('脚手架', now - 500); + await redis.del(`msg:${msg.id}`); // timeline entry survives, hash gone → collection gap + + const reconcile = await service.reconcileWindow(OWNER, now - 1000, now); + assert.equal(reconcile.ok, false, 'partial window must not report as reconciled'); + + const result = await service.computeWordCounts(OWNER, now - 1000, now); + assert.equal(result.unmeasurable, true); + assert.equal(result.reason, 'reconcile_failed'); + }); +}); diff --git a/packages/api/test/mark-all-read.test.js b/packages/api/test/mark-all-read.test.js index c0549adc5d..7474514131 100644 --- a/packages/api/test/mark-all-read.test.js +++ b/packages/api/test/mark-all-read.test.js @@ -64,6 +64,7 @@ describe('POST /api/threads/read/mark-all', () => { // Add messages to each thread for (const t of threads) { messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'alice', catId: 'opus', content: `msg1 in ${t.id}`, @@ -72,6 +73,7 @@ describe('POST /api/threads/read/mark-all', () => { threadId: t.id, }); messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'alice', catId: 'opus', content: `msg2 in ${t.id}`, @@ -96,6 +98,7 @@ describe('POST /api/threads/read/mark-all', () => { it('is idempotent — second call advances 0', async () => { const t = threadStore.create('alice', 'Thread X'); messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'alice', catId: 'opus', content: 'hello', diff --git a/packages/api/test/memory/event-memory-store.test.js b/packages/api/test/memory/event-memory-store.test.js index 15ade5a0b1..0df2ee51a6 100644 --- a/packages/api/test/memory/event-memory-store.test.js +++ b/packages/api/test/memory/event-memory-store.test.js @@ -191,6 +191,74 @@ describe('EventMemoryStore (F227 PR-1)', () => { }); }); + describe('delete lifecycle', () => { + it('deletes all owner-scoped events at a message coordinate', () => { + store.markEvent(baseRecord({ threadId: 'thread_delete', messageId: 'msg_delete', type: '绕路了' }), 'owner-A'); + store.markEvent(baseRecord({ threadId: 'thread_delete', messageId: 'msg_delete', type: '脚手架' }), 'owner-B'); + store.markEvent(baseRecord({ threadId: 'thread_delete', messageId: 'keep', type: '第一性原理' }), 'owner-A'); + store.appendDeadLetter( + baseRecord({ threadId: 'thread_delete', messageId: 'msg_delete', summary: 'dead-letter excerpt' }), + 'owner-A', + 'simulated failure', + ); + store.appendDeadLetter( + baseRecord({ threadId: 'thread_delete', messageId: 'keep', summary: 'keep excerpt' }), + 'owner-A', + 'simulated failure', + ); + + assert.equal(store.deleteByCoord('thread_delete', 'msg_delete'), 3); + assert.deepEqual(store.getByCoord('thread_delete', 'msg_delete'), []); + assert.equal(store.getByCoord('thread_delete', 'keep').length, 1); + assert.deepEqual( + store.listDeadLetter().map((entry) => entry.record.messageId), + ['keep'], + ); + assert.throws( + () => store.markEvent(baseRecord({ threadId: 'thread_delete', messageId: 'msg_delete' }), OWNER), + /deleted coordinate/i, + ); + assert.throws( + () => + store.appendDeadLetter( + baseRecord({ threadId: 'thread_delete', messageId: 'msg_delete', summary: 'stale excerpt' }), + OWNER, + 'late writer', + ), + /deleted coordinate/i, + ); + }); + + it('deletes all event excerpts for a physically deleted thread', () => { + store.markEvent(baseRecord({ threadId: 'thread_delete', messageId: 'm1', type: '绕路了' }), OWNER); + store.markEvent(baseRecord({ threadId: 'thread_delete', messageId: 'm2', type: '脚手架' }), OWNER); + store.markEvent(baseRecord({ threadId: 'thread_keep', messageId: 'm3', type: '第一性原理' }), OWNER); + store.appendDeadLetter( + baseRecord({ threadId: 'thread_delete', messageId: 'm4', summary: 'dead-letter excerpt' }), + OWNER, + 'simulated failure', + ); + + assert.equal(store.deleteByThread('thread_delete'), 3); + assert.deepEqual(store.listEvents({ threadId: 'thread_delete' }), []); + assert.equal(store.listEvents({ threadId: 'thread_keep' }).length, 1); + assert.deepEqual(store.listDeadLetter(), []); + assert.throws( + () => store.markEvent(baseRecord({ threadId: 'thread_delete', messageId: 'late' }), OWNER), + /deleted thread/i, + ); + assert.throws( + () => + store.appendDeadLetter( + baseRecord({ threadId: 'thread_delete', messageId: 'late', summary: 'stale thread excerpt' }), + OWNER, + 'late writer', + ), + /deleted thread/i, + ); + }); + }); + describe('health', () => { it('reports healthy after initialize', () => { assert.equal(store.health(), true); diff --git a/packages/api/test/memory/f200-trajectory-schema.test.js b/packages/api/test/memory/f200-trajectory-schema.test.js index d0874891b9..5e7f4444b1 100644 --- a/packages/api/test/memory/f200-trajectory-schema.test.js +++ b/packages/api/test/memory/f200-trajectory-schema.test.js @@ -97,8 +97,8 @@ describe('F200 Phase D — task_trajectories schema V22', () => { assert.equal(row.duration, 45000); }); - it('reaches schema version 26', () => { + it('reaches schema version 27', () => { const row = db.prepare('SELECT MAX(version) as v FROM schema_version').get(); - assert.equal(row.v, 26); + assert.equal(row.v, 27); }); }); diff --git a/packages/api/test/memory/schema-v17.test.js b/packages/api/test/memory/schema-v17.test.js index 0e341fc8ac..02add95366 100644 --- a/packages/api/test/memory/schema-v17.test.js +++ b/packages/api/test/memory/schema-v17.test.js @@ -4,9 +4,9 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; describe('Schema V17 migration', () => { - it('CURRENT_SCHEMA_VERSION is 26', async () => { + it('CURRENT_SCHEMA_VERSION is 27', async () => { const { CURRENT_SCHEMA_VERSION } = await import('../../dist/domains/memory/schema.js'); - assert.equal(CURRENT_SCHEMA_VERSION, 26); + assert.equal(CURRENT_SCHEMA_VERSION, 27); }); it('V17 adds collection_id and review_status to evidence_docs', async () => { diff --git a/packages/api/test/memory/schema-v19-f200.test.js b/packages/api/test/memory/schema-v19-f200.test.js index 7e3a2abddb..d3ab09315e 100644 --- a/packages/api/test/memory/schema-v19-f200.test.js +++ b/packages/api/test/memory/schema-v19-f200.test.js @@ -89,9 +89,9 @@ describe('V19 migration — F200 recall_events + edge traversal columns', () => db.close(); }); - it('CURRENT_SCHEMA_VERSION is 26', async () => { + it('CURRENT_SCHEMA_VERSION is 27', async () => { const { CURRENT_SCHEMA_VERSION } = await import('../../dist/domains/memory/schema.js'); - assert.equal(CURRENT_SCHEMA_VERSION, 26); + assert.equal(CURRENT_SCHEMA_VERSION, 27); }); it('can insert and read recall_events', async () => { diff --git a/packages/api/test/memory/schema-v2.test.js b/packages/api/test/memory/schema-v2.test.js index 4defc7cff5..e9470b89e1 100644 --- a/packages/api/test/memory/schema-v2.test.js +++ b/packages/api/test/memory/schema-v2.test.js @@ -80,6 +80,6 @@ describe('Schema V2 migration', () => { it('CURRENT_SCHEMA_VERSION matches expected value', async () => { const { CURRENT_SCHEMA_VERSION } = await import('../../dist/domains/memory/schema.js'); - assert.equal(CURRENT_SCHEMA_VERSION, 26, `expected 26, got ${CURRENT_SCHEMA_VERSION}`); + assert.equal(CURRENT_SCHEMA_VERSION, 27, `expected 27, got ${CURRENT_SCHEMA_VERSION}`); }); }); diff --git a/packages/api/test/memory/schema-v26-recall-result-count.test.js b/packages/api/test/memory/schema-v26-recall-result-count.test.js index 9afdacf59e..fb9c25cdcb 100644 --- a/packages/api/test/memory/schema-v26-recall-result-count.test.js +++ b/packages/api/test/memory/schema-v26-recall-result-count.test.js @@ -17,7 +17,7 @@ describe('V26 migration — recall_events result_count', () => { const resultCount = cols.find((col) => col.name === 'result_count'); assert.ok(resultCount, 'result_count column exists'); assert.equal(resultCount.notnull, 0, 'result_count is nullable so old rows can stay unknown'); - assert.equal(schema.CURRENT_SCHEMA_VERSION, 26); + assert.equal(schema.CURRENT_SCHEMA_VERSION, 27); } finally { db.close(); } diff --git a/packages/api/test/memory/schema-v27.test.js b/packages/api/test/memory/schema-v27.test.js new file mode 100644 index 0000000000..f0a6d13492 --- /dev/null +++ b/packages/api/test/memory/schema-v27.test.js @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +describe('V27 migration — dynamic_task_defs retry_attempts', () => { + it('adds retry_attempts with default 0 for durable once-task retry progress', async () => { + const Database = (await import('better-sqlite3')).default; + const schema = await import('../../dist/domains/memory/schema.js'); + + const db = new Database(':memory:'); + try { + db.exec('PRAGMA journal_mode = WAL'); + db.exec(schema.SCHEMA_V1); + db.prepare('INSERT INTO schema_version (version, applied_at) VALUES (?, ?)').run(1, new Date().toISOString()); + schema.applyMigrations(db); + + const cols = db.prepare("PRAGMA table_info('dynamic_task_defs')").all(); + const retryAttempts = cols.find((col) => col.name === 'retry_attempts'); + assert.ok(retryAttempts, 'retry_attempts column exists'); + assert.equal(retryAttempts.dflt_value, '0', 'retry_attempts defaults to 0'); + assert.equal(retryAttempts.notnull, 0, 'retry_attempts is nullable for backwards compat'); + + const version = db.prepare('SELECT MAX(version) as v FROM schema_version').get(); + assert.equal(version.v, schema.CURRENT_SCHEMA_VERSION); + assert.equal(schema.CURRENT_SCHEMA_VERSION, 27); + } finally { + db.close(); + } + }); + + it('idempotent: applying migrations twice leaves schema_version at 27', async () => { + const Database = (await import('better-sqlite3')).default; + const schema = await import('../../dist/domains/memory/schema.js'); + + const db = new Database(':memory:'); + try { + db.exec('PRAGMA journal_mode = WAL'); + schema.applyMigrations(db); + schema.applyMigrations(db); + + const version = db.prepare('SELECT MAX(version) as v FROM schema_version').get(); + assert.equal(version.v, 27); + } finally { + db.close(); + } + }); +}); diff --git a/packages/api/test/memory/world-scope-filter.test.js b/packages/api/test/memory/world-scope-filter.test.js index fcc6f6fb40..648b9cf230 100644 --- a/packages/api/test/memory/world-scope-filter.test.js +++ b/packages/api/test/memory/world-scope-filter.test.js @@ -4,8 +4,8 @@ import Database from 'better-sqlite3'; import { applyMigrations, CURRENT_SCHEMA_VERSION } from '../../dist/domains/memory/schema.js'; describe('Schema V16 (F093 world scope)', () => { - it('CURRENT_SCHEMA_VERSION is 26', () => { - assert.equal(CURRENT_SCHEMA_VERSION, 26); + it('CURRENT_SCHEMA_VERSION is 27', () => { + assert.equal(CURRENT_SCHEMA_VERSION, 27); }); it('migration adds world_id and scene_id columns', () => { diff --git a/packages/api/test/mention-ack.test.js b/packages/api/test/mention-ack.test.js index cb72174985..6aee787d13 100644 --- a/packages/api/test/mention-ack.test.js +++ b/packages/api/test/mention-ack.test.js @@ -61,6 +61,7 @@ describe('Mention Ack (#77)', () => { function appendMention(threadId, content, ts) { return messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content, @@ -187,6 +188,7 @@ describe('Mention Ack (#77)', () => { // Message from user-1 mentioning codex (not opus) const mCodex = messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: '@codex review', diff --git a/packages/api/test/mention-parser.test.js b/packages/api/test/mention-parser.test.js index 69e55fdb4f..51a88879b2 100644 --- a/packages/api/test/mention-parser.test.js +++ b/packages/api/test/mention-parser.test.js @@ -2,9 +2,10 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { parseMentions } from '../dist/infrastructure/connectors/mention-parser.js'; +// P1-4: @宪宪/@砚砚 removed from breeds → patterns aligned with cat-template.json const allPatterns = new Map([ - ['opus', ['@opus', '@布偶猫', '@布偶', '@宪宪']], - ['codex', ['@codex', '@缅因猫', '@缅因', '@砚砚']], + ['opus', ['@opus', '@布偶猫', '@布偶', '@ragdoll']], + ['codex', ['@codex', '@缅因猫', '@缅因', '@maine']], ['gemini', ['@gemini', '@暹罗猫', '@暹罗', '@烁烁']], ]); @@ -19,8 +20,9 @@ describe('parseMentions', () => { assert.equal(result.targetCatId, 'codex'); }); - it('returns matched catId for @砚砚 (nickname)', () => { - const result = parseMentions('@砚砚 你看看这个', allPatterns, 'opus'); + // P1-4: @砚砚 removed → use @maine (still valid codex alias) + it('returns matched catId for @maine (alias)', () => { + const result = parseMentions('@maine 你看看这个', allPatterns, 'opus'); assert.equal(result.targetCatId, 'codex'); }); @@ -76,7 +78,7 @@ describe('parseMentions', () => { }); it('matches @mention followed by Chinese full-width exclamation', () => { - const result = parseMentions('@砚砚!快来', allPatterns, 'opus'); + const result = parseMentions('@缅因猫!快来', allPatterns, 'opus'); assert.equal(result.targetCatId, 'codex'); }); diff --git a/packages/api/test/message-delivered-at.test.js b/packages/api/test/message-delivered-at.test.js index f74202a119..d8cbabcc00 100644 --- a/packages/api/test/message-delivered-at.test.js +++ b/packages/api/test/message-delivered-at.test.js @@ -14,6 +14,7 @@ describe('MessageStore.markDelivered', () => { test('sets deliveredAt on a queued message', () => { const store = new MessageStore(); const msg = store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u1', catId: null, content: 'queued message', @@ -40,6 +41,7 @@ describe('MessageStore.markDelivered', () => { test('deliveredAt is persisted and visible via getById', () => { const store = new MessageStore(); const msg = store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u1', catId: null, content: 'test', @@ -58,6 +60,7 @@ describe('MessageStore.markDelivered', () => { test('deliveredAt field exists on StoredMessage type (not set by default)', () => { const store = new MessageStore(); const msg = store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u1', catId: null, content: 'immediate message', @@ -74,6 +77,7 @@ describe('MessageStore.getByThreadAfter', () => { test('falls back to lexicographic ID filtering when cursor message is missing', () => { const store = new MessageStore(); store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, threadId: 'thread-a', userId: 'u1', catId: null, @@ -82,6 +86,7 @@ describe('MessageStore.getByThreadAfter', () => { timestamp: 1000, }); const afterCursor = store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, threadId: 'thread-a', userId: 'u1', catId: null, @@ -90,6 +95,7 @@ describe('MessageStore.getByThreadAfter', () => { timestamp: 2000, }); store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, threadId: 'thread-b', userId: 'u1', catId: null, diff --git a/packages/api/test/message-store.test.js b/packages/api/test/message-store.test.js index a106749542..25b44fa03d 100644 --- a/packages/api/test/message-store.test.js +++ b/packages/api/test/message-store.test.js @@ -6,12 +6,15 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; +const USER_PROVENANCE = { author: 'user', routed: false, observation: 'original' }; + describe('MessageStore', () => { test('append() stores message and returns with id', async () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); const result = store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'Hello', @@ -45,6 +48,7 @@ describe('MessageStore', () => { assert.throws( () => store.append({ + provenance: USER_PROVENANCE, userId: 'user-1', catId: null, content: 'must not persist', @@ -65,6 +69,7 @@ describe('MessageStore', () => { for (const timestamp of [0, 1, 8_640_000_000_000_000]) { const stored = store.append({ + provenance: USER_PROVENANCE, userId: 'user-1', catId: null, content: 'valid Date input', @@ -81,6 +86,7 @@ describe('MessageStore', () => { let listenerCalls = 0; const store = new MessageStore({ onAppend: () => listenerCalls++ }); const base = { + provenance: USER_PROVENANCE, userId: 'user-1', catId: null, content: 'delivery ownership probe', @@ -119,6 +125,7 @@ describe('MessageStore', () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); const base = { + provenance: USER_PROVENANCE, userId: 'user-cancel-owner', catId: null, mentions: [], @@ -162,6 +169,7 @@ describe('MessageStore', () => { for (const [index, deliveredAt] of invalidTimestamps.entries()) { const store = new MessageStore(); const queued = store.append({ + provenance: USER_PROVENANCE, userId: 'user-1', catId: null, content: `queued ${index}`, @@ -199,6 +207,7 @@ describe('MessageStore', () => { const store = new MessageStore(); const threadId = 'thread-delivery-admission-pagination'; const first = store.append({ + provenance: USER_PROVENANCE, userId: 'user-1', catId: null, content: 'first', @@ -208,6 +217,7 @@ describe('MessageStore', () => { deliveryStatus: 'queued', }); const second = store.append({ + provenance: USER_PROVENANCE, userId: 'user-1', catId: null, content: 'second', @@ -251,6 +261,7 @@ describe('MessageStore', () => { const store = new MessageStore(); const later = [2, 8_640_000_000_000_000].map((timestamp) => store.append({ + provenance: USER_PROVENANCE, userId: 'user-1', catId: null, content: `timestamp ${timestamp}`, @@ -272,6 +283,7 @@ describe('MessageStore', () => { const store = new MessageStore(); const callbackMsg = store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'opus', content: 'Callback content remains canonical', @@ -315,6 +327,7 @@ describe('MessageStore', () => { for (let i = 0; i < 5; i++) { store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: `Message ${i}`, @@ -336,6 +349,7 @@ describe('MessageStore', () => { const store = new MessageStore(); store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: '@opus help', @@ -343,6 +357,7 @@ describe('MessageStore', () => { timestamp: 1, }); store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: '@codex review', @@ -350,6 +365,7 @@ describe('MessageStore', () => { timestamp: 2, }); store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: '@opus and @codex', @@ -373,6 +389,7 @@ describe('MessageStore', () => { for (let i = 0; i < 8; i++) { store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: `Message ${i}`, @@ -391,9 +408,30 @@ describe('MessageStore', () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); - store.append({ userId: 'user-1', catId: null, content: 'A from user-1', mentions: [], timestamp: 1 }); - store.append({ userId: 'user-2', catId: null, content: 'B from user-2', mentions: [], timestamp: 2 }); - store.append({ userId: 'user-1', catId: 'opus', content: 'C from user-1 opus', mentions: [], timestamp: 3 }); + store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'user-1', + catId: null, + content: 'A from user-1', + mentions: [], + timestamp: 1, + }); + store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'user-2', + catId: null, + content: 'B from user-2', + mentions: [], + timestamp: 2, + }); + store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, + userId: 'user-1', + catId: 'opus', + content: 'C from user-1 opus', + mentions: [], + timestamp: 3, + }); const user1 = store.getRecent(10, 'user-1'); assert.equal(user1.length, 2); @@ -413,8 +451,22 @@ describe('MessageStore', () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); - store.append({ userId: 'user-1', catId: null, content: '@opus from user-1', mentions: ['opus'], timestamp: 1 }); - store.append({ userId: 'user-2', catId: null, content: '@opus from user-2', mentions: ['opus'], timestamp: 2 }); + store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'user-1', + catId: null, + content: '@opus from user-1', + mentions: ['opus'], + timestamp: 1, + }); + store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'user-2', + catId: null, + content: '@opus from user-2', + mentions: ['opus'], + timestamp: 2, + }); const user1Mentions = store.getMentionsFor('opus', 10, 'user-1'); assert.equal(user1Mentions.length, 1); @@ -430,6 +482,7 @@ describe('MessageStore', () => { const store = new MessageStore(); store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: '@opus in thread-A', @@ -438,6 +491,7 @@ describe('MessageStore', () => { threadId: 'thread-A', }); store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: '@opus in thread-B', @@ -446,6 +500,7 @@ describe('MessageStore', () => { threadId: 'thread-B', }); store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: '@opus in thread-A again', @@ -475,6 +530,7 @@ describe('MessageStore', () => { const store = new MessageStore(); store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: '@opus u1-tA', @@ -483,6 +539,7 @@ describe('MessageStore', () => { threadId: 'thread-A', }); store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-2', catId: null, content: '@opus u2-tA', @@ -491,6 +548,7 @@ describe('MessageStore', () => { threadId: 'thread-A', }); store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: '@opus u1-tB', @@ -509,9 +567,30 @@ describe('MessageStore', () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); - store.append({ userId: 'u', catId: null, content: 'old', mentions: [], timestamp: 100 }); - store.append({ userId: 'u', catId: null, content: 'mid', mentions: [], timestamp: 200 }); - store.append({ userId: 'u', catId: null, content: 'new', mentions: [], timestamp: 300 }); + store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'u', + catId: null, + content: 'old', + mentions: [], + timestamp: 100, + }); + store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'u', + catId: null, + content: 'mid', + mentions: [], + timestamp: 200, + }); + store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'u', + catId: null, + content: 'new', + mentions: [], + timestamp: 300, + }); const before = store.getBefore(300, 10); assert.equal(before.length, 2); @@ -523,9 +602,30 @@ describe('MessageStore', () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); - store.append({ userId: 'alice', catId: null, content: 'alice old', mentions: [], timestamp: 100 }); - store.append({ userId: 'bob', catId: null, content: 'bob old', mentions: [], timestamp: 150 }); - store.append({ userId: 'alice', catId: null, content: 'alice new', mentions: [], timestamp: 200 }); + store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'alice', + catId: null, + content: 'alice old', + mentions: [], + timestamp: 100, + }); + store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'bob', + catId: null, + content: 'bob old', + mentions: [], + timestamp: 150, + }); + store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'alice', + catId: null, + content: 'alice new', + mentions: [], + timestamp: 200, + }); const before = store.getBefore(200, 10, 'alice'); assert.equal(before.length, 1); @@ -548,6 +648,7 @@ describe('MessageStore', () => { const store = new MessageStore(); const msg = store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u1', catId: null, content: 'hi', @@ -562,6 +663,7 @@ describe('MessageStore', () => { const store = new MessageStore(); const msg = store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u1', catId: null, content: 'hi', @@ -577,6 +679,7 @@ describe('MessageStore', () => { const store = new MessageStore(); const first = store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u1', catId: null, content: 'kickoff', @@ -587,6 +690,7 @@ describe('MessageStore', () => { }); const second = store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u1', catId: null, content: 'kickoff retried', @@ -605,10 +709,41 @@ describe('MessageStore', () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); - store.append({ userId: 'u', catId: null, content: 'A', mentions: [], timestamp: 1, threadId: 'th-1' }); - store.append({ userId: 'u', catId: null, content: 'B', mentions: [], timestamp: 2, threadId: 'th-2' }); - store.append({ userId: 'u', catId: null, content: 'C', mentions: [], timestamp: 3, threadId: 'th-1' }); - store.append({ userId: 'u', catId: null, content: 'D', mentions: [], timestamp: 4 }); // default thread + store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'u', + catId: null, + content: 'A', + mentions: [], + timestamp: 1, + threadId: 'th-1', + }); + store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'u', + catId: null, + content: 'B', + mentions: [], + timestamp: 2, + threadId: 'th-2', + }); + store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'u', + catId: null, + content: 'C', + mentions: [], + timestamp: 3, + threadId: 'th-1', + }); + store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'u', + catId: null, + content: 'D', + mentions: [], + timestamp: 4, + }); // default thread const th1 = store.getByThread('th-1'); assert.equal(th1.length, 2); @@ -628,10 +763,42 @@ describe('MessageStore', () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); - store.append({ userId: 'u', catId: null, content: 'A', mentions: [], timestamp: 100, threadId: 'th-1' }); - store.append({ userId: 'u', catId: null, content: 'B', mentions: [], timestamp: 200, threadId: 'th-1' }); - store.append({ userId: 'u', catId: null, content: 'C', mentions: [], timestamp: 300, threadId: 'th-1' }); - store.append({ userId: 'u', catId: null, content: 'X', mentions: [], timestamp: 250, threadId: 'th-2' }); // different thread + store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'u', + catId: null, + content: 'A', + mentions: [], + timestamp: 100, + threadId: 'th-1', + }); + store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'u', + catId: null, + content: 'B', + mentions: [], + timestamp: 200, + threadId: 'th-1', + }); + store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'u', + catId: null, + content: 'C', + mentions: [], + timestamp: 300, + threadId: 'th-1', + }); + store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'u', + catId: null, + content: 'X', + mentions: [], + timestamp: 250, + threadId: 'th-2', + }); // different thread const before300 = store.getByThreadBefore('th-1', 300, 10); assert.equal(before300.length, 2); @@ -648,6 +815,7 @@ describe('MessageStore', () => { { type: 'image', url: '/uploads/test.png' }, ]; const msg = store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u', catId: null, content: 'hello', @@ -667,6 +835,7 @@ describe('MessageStore', () => { { id: 'toolr-1', type: 'tool_result', label: 'opus ← result', detail: 'file content...', timestamp: 1001 }, ]; const msg = store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'u', catId: 'opus', content: 'done', @@ -687,6 +856,7 @@ describe('MessageStore', () => { const store = new MessageStore(); const msg = store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'u', catId: 'opus', content: 'hi', @@ -706,6 +876,7 @@ describe('MessageStore', () => { const store = new MessageStore(); const msg = store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'u', catId: 'opus', content: 'response', @@ -720,14 +891,138 @@ describe('MessageStore', () => { assert.equal(deleted.thinking, undefined, 'thinking must be cleared on hard delete'); }); + test('R8: deletion hooks run before mutation and hardDelete scrubs F257 fields', async () => { + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + const calls = []; + const store = new MessageStore({ + onBeforeHardDelete: (msg) => calls.push(`hard:${msg.threadId}:${msg.id}`), + onBeforeDeleteByThread: (threadId) => calls.push(`thread:${threadId}`), + }); + const routingFact = { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [ + { tokenOrdinal: 0, outcome: 'resolved', token: '@opus', span: { start: 0, end: 5 }, targetCatId: 'opus' }, + ], + truncated: false, + metricEligible: true, + }; + const msg = store.append({ + provenance: { author: 'user', routed: true, observation: 'original' }, + routingFact, + userId: 'u', + catId: null, + content: '@opus private request', + mentions: ['opus'], + timestamp: 1, + threadId: 'thread-delete-hooks', + }); + + const deleted = store.hardDelete(msg.id, 'admin'); + assert.equal(deleted.routingFact, undefined); + assert.equal(deleted.provenance, undefined); + assert.deepEqual(calls, [`hard:${msg.threadId}:${msg.id}`]); + + store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'u', + catId: null, + content: 'thread cleanup', + mentions: [], + timestamp: 2, + threadId: 'thread-delete-hooks', + }); + assert.equal(store.deleteByThread('thread-delete-hooks'), 2); + assert.equal(calls.at(-1), 'thread:thread-delete-hooks'); + }); + + test('R8: a failed derivative scrub aborts hard deletion before authority mutation', async () => { + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + const store = new MessageStore({ + onBeforeHardDelete: () => { + throw new Error('event-memory unavailable'); + }, + }); + const msg = store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'u', + catId: null, + content: 'must remain authoritative', + mentions: [], + timestamp: 1, + }); + + assert.throws(() => store.hardDelete(msg.id, 'admin'), /event-memory unavailable/); + const unchanged = store.getById(msg.id); + assert.equal(unchanged.content, 'must remain authoritative'); + assert.equal(unchanged.deletedAt, undefined); + assert.equal(unchanged._tombstone, undefined); + }); + + test('R10: hard tombstones are immutable across every in-memory message mutator', async () => { + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + const store = new MessageStore(); + const msg = store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, + userId: 'u', + catId: 'opus', + content: 'sensitive payload', + mentions: [], + timestamp: 1, + threadId: 'thread-r10-terminal', + visibility: 'whisper', + deliveryStatus: 'queued', + extra: { stream: { invocationId: 'old-invocation' } }, + thinking: 'sensitive thinking', + }); + const deleted = store.hardDelete(msg.id, 'admin'); + assert.ok(deleted); + const tombstone = structuredClone(store.getById(msg.id)); + + const results = { + softDelete: store.softDelete(msg.id, 'other-admin'), + restore: store.restore(msg.id), + hardDelete: store.hardDelete(msg.id, 'other-admin'), + updateExtra: store.updateExtra(msg.id, { tracing: { traceId: 'revived', spanId: 'revived' } }), + augment: store.augmentStreamMetadata(msg.id, { + thinking: 'revived thinking', + toolEvents: [{ id: 'revived-tool', type: 'tool_use', label: 'revived', timestamp: 2 }], + }), + delivered: store.markDelivered(msg.id, 3), + canceled: store.markCanceled(msg.id), + revealed: store.revealWhispers(msg.threadId, msg.userId), + }; + + assert.deepEqual(results, { + softDelete: null, + restore: null, + hardDelete: null, + updateExtra: null, + augment: null, + delivered: null, + canceled: null, + revealed: 0, + }); + assert.deepEqual(store.getById(msg.id), tombstone, 'terminal tombstone bytes remain unchanged'); + }); + // --- System-user visibility (scheduler messages must be visible to all) --- test('getByThread() includes scheduler messages when filtering by userId', async () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); - store.append({ userId: 'user-1', catId: 'opus', content: 'hello', mentions: [], timestamp: 1, threadId: 'th' }); store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, + userId: 'user-1', + catId: 'opus', + content: 'hello', + mentions: [], + timestamp: 1, + threadId: 'th', + }); + store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'scheduler', catId: 'system', content: '[定时任务] reminder', @@ -735,7 +1030,15 @@ describe('MessageStore', () => { timestamp: 2, threadId: 'th', }); - store.append({ userId: 'user-2', catId: null, content: 'other user', mentions: [], timestamp: 3, threadId: 'th' }); + store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'user-2', + catId: null, + content: 'other user', + mentions: [], + timestamp: 3, + threadId: 'th', + }); const msgs = store.getByThread('th', 50, 'user-1'); assert.equal(msgs.length, 2, 'should include own message + scheduler message'); @@ -747,8 +1050,17 @@ describe('MessageStore', () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); - store.append({ userId: 'user-1', catId: 'opus', content: 'hello', mentions: [], timestamp: 100, threadId: 'th' }); store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, + userId: 'user-1', + catId: 'opus', + content: 'hello', + mentions: [], + timestamp: 100, + threadId: 'th', + }); + store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'scheduler', catId: 'system', content: '[定时任务] reminder', @@ -756,7 +1068,15 @@ describe('MessageStore', () => { timestamp: 200, threadId: 'th', }); - store.append({ userId: 'user-1', catId: null, content: 'follow-up', mentions: [], timestamp: 300, threadId: 'th' }); + store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'user-1', + catId: null, + content: 'follow-up', + mentions: [], + timestamp: 300, + threadId: 'th', + }); const msgs = store.getByThreadBefore('th', 350, 50, undefined, 'user-1'); assert.equal(msgs.length, 3, 'should include all own messages + scheduler'); @@ -768,6 +1088,7 @@ describe('MessageStore', () => { const store = new MessageStore(); const first = store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'start', @@ -776,6 +1097,7 @@ describe('MessageStore', () => { threadId: 'th', }); store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'scheduler', catId: 'system', content: '[定时任务] digest', @@ -783,7 +1105,15 @@ describe('MessageStore', () => { timestamp: 200, threadId: 'th', }); - store.append({ userId: 'user-2', catId: null, content: 'other', mentions: [], timestamp: 300, threadId: 'th' }); + store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'user-2', + catId: null, + content: 'other', + mentions: [], + timestamp: 300, + threadId: 'th', + }); const msgs = store.getByThreadAfter('th', first.id, undefined, 'user-1'); assert.equal(msgs.length, 1, 'should include scheduler message after cursor'); @@ -794,8 +1124,17 @@ describe('MessageStore', () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); - store.append({ userId: 'user-1', catId: 'opus', content: 'legit', mentions: [], timestamp: 1, threadId: 'th' }); store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, + userId: 'user-1', + catId: 'opus', + content: 'legit', + mentions: [], + timestamp: 1, + threadId: 'th', + }); + store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'scheduler', catId: 'opus', content: 'forged system message', @@ -813,8 +1152,17 @@ describe('MessageStore', () => { const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const store = new MessageStore(); - store.append({ userId: 'user-1', catId: null, content: 'hello', mentions: [], timestamp: 1, threadId: 'th' }); store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'user-1', + catId: null, + content: 'hello', + mentions: [], + timestamp: 1, + threadId: 'th', + }); + store.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, userId: 'system', catId: null, content: 'Error: stream_idle_stall: Gemini stopped responding', @@ -829,3 +1177,101 @@ describe('MessageStore', () => { assert.equal(msgs[1].catId, null); }); }); + +describe('F257 V1: routingFact embedded authority (in-memory)', () => { + const SAMPLE_BATCH = { + parserMode: 'a2a', + spanBasis: 'a2a_normalized', + attempts: [ + { tokenOrdinal: 0, outcome: 'resolved', token: '@codex', span: { start: 0, end: 6 }, targetCatId: 'codex' }, + ], + truncated: false, + metricEligible: true, + }; + + test('append() embeds routingFact and getById returns it (co-fate with message)', async () => { + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + const store = new MessageStore(); + const stored = store.append({ + userId: 'user-1', + catId: 'opus', + content: '@codex 看下', + mentions: ['codex'], + timestamp: 1, + threadId: 'th', + routingFact: SAMPLE_BATCH, + provenance: { author: 'cat', routed: true, observation: 'original' }, + }); + assert.deepEqual(stored.routingFact, SAMPLE_BATCH); + assert.deepEqual(store.getById(stored.id)?.routingFact, SAMPLE_BATCH); + }); + + test('append() persists an empty-attempts batch (producer-run marker, sol R1 P1-1)', async () => { + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + const store = new MessageStore(); + const emptyBatch = { ...SAMPLE_BATCH, attempts: [] }; + const stored = store.append({ + userId: 'user-1', + catId: null, + content: 'no mentions here', + mentions: [], + timestamp: 1, + threadId: 'th', + routingFact: emptyBatch, + provenance: { author: 'user', routed: true, observation: 'original' }, + }); + assert.deepEqual(stored.routingFact, emptyBatch); + assert.deepEqual(store.getById(stored.id)?.routingFact, emptyBatch); + }); + + test('append() enforces provenance consistency at the write boundary (sol R3 P1-1)', async () => { + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + const store = new MessageStore(); + const base = { userId: 'u', content: 'x', mentions: [], timestamp: 1, threadId: 'th' }; + // routed declared but no fact + assert.throws( + () => + store.append({ ...base, catId: null, provenance: { author: 'user', routed: true, observation: 'original' } }), + /routingFact/, + ); + // fact present but not declared routed + assert.throws( + () => + store.append({ + ...base, + catId: null, + routingFact: { ...SAMPLE_BATCH, attempts: [] }, + provenance: { author: 'user', routed: false, observation: 'original' }, + }), + /routed/, + ); + // author user requires catId null + assert.throws( + () => + store.append({ + ...base, + catId: 'opus', + provenance: { author: 'user', routed: false, observation: 'original' }, + }), + /catId null/, + ); + // author cat requires a catId + assert.throws( + () => + store.append({ ...base, catId: null, provenance: { author: 'cat', routed: false, observation: 'original' } }), + /requires a catId/, + ); + // sol R4 P1-1b: an uncompiled caller can no longer skip the declaration — + // provenance is runtime-required with a validated domain at the boundary + assert.throws(() => store.append({ ...base, catId: null }), /append requires provenance/); + assert.throws( + () => + store.append({ ...base, catId: null, provenance: { author: 'relay', routed: false, observation: 'original' } }), + /author must be one of/, + ); + assert.throws( + () => store.append({ ...base, catId: null, provenance: { author: 'user', routed: 1, observation: 'original' } }), + /routed must be a boolean/, + ); + }); +}); diff --git a/packages/api/test/messages-decision-notification-route.test.js b/packages/api/test/messages-decision-notification-route.test.js index 1f3a51536d..9792fbb180 100644 --- a/packages/api/test/messages-decision-notification-route.test.js +++ b/packages/api/test/messages-decision-notification-route.test.js @@ -23,6 +23,13 @@ function buildDeps() { }, router: { resolveTargetsAndIntent: mock.fn(async () => ({ + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, targetCats: ['opus'], intent: { intent: 'execute' }, })), diff --git a/packages/api/test/messages-delivery-mode.test.js b/packages/api/test/messages-delivery-mode.test.js index c52685e4ae..19f3b16c9b 100644 --- a/packages/api/test/messages-delivery-mode.test.js +++ b/packages/api/test/messages-delivery-mode.test.js @@ -30,6 +30,13 @@ function buildDeps(overrides = {}) { }, router: { resolveTargetsAndIntent: mock.fn(async () => ({ + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, targetCats: ['opus'], intent: { intent: 'execute' }, })), @@ -722,6 +729,13 @@ describe('POST /api/messages deliveryMode', () => { it('immediate multi-cat execution schedules continuation for the capsule owner cat', async () => { deps.invocationTracker.has.mock.mockImplementation(() => false); deps.router.resolveTargetsAndIntent.mock.mockImplementation(async () => ({ + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, targetCats: ['opus', 'codex'], intent: { intent: 'execute' }, })); @@ -769,6 +783,13 @@ describe('POST /api/messages deliveryMode', () => { it('immediate multi-cat execution schedules continuation for every sealed cat', async () => { deps.invocationTracker.has.mock.mockImplementation(() => false); deps.router.resolveTargetsAndIntent.mock.mockImplementation(async () => ({ + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, targetCats: ['opus', 'codex'], intent: { intent: 'execute' }, })); @@ -964,6 +985,13 @@ describe('POST /api/messages deliveryMode', () => { deps.invocationTracker.tryStartThread.mock.mockImplementation(() => controller); deps.invocationTracker.tryStartThreadAll.mock.mockImplementation(() => controller); deps.router.resolveTargetsAndIntent.mock.mockImplementation(async () => ({ + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, targetCats: ['gemini', 'opus'], intent: { intent: 'execute' }, })); @@ -1005,6 +1033,13 @@ describe('POST /api/messages deliveryMode', () => { it('F148 fix: exception after partial completion still acks collected cursors', async () => { deps.router.resolveTargetsAndIntent.mock.mockImplementation(async () => ({ + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, targetCats: ['gemini', 'opus'], intent: { intent: 'execute' }, })); diff --git a/packages/api/test/messages-endpoint.test.js b/packages/api/test/messages-endpoint.test.js index e15d6610d0..6477327aff 100644 --- a/packages/api/test/messages-endpoint.test.js +++ b/packages/api/test/messages-endpoint.test.js @@ -40,6 +40,7 @@ describe('GET /api/messages', () => { it('returns messages with correct format', async () => { messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'default-user', catId: null, content: 'hello', @@ -47,6 +48,7 @@ describe('GET /api/messages', () => { timestamp: 1000, }); messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'default-user', catId: 'opus', content: 'hi there', @@ -71,6 +73,7 @@ describe('GET /api/messages', () => { it('preserves explicit post flag with stream identity in history response', async () => { messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'default-user', catId: 'opus', content: 'standalone post', @@ -94,8 +97,30 @@ describe('GET /api/messages', () => { }); }); + // F257 #4 (sol R1 P2-1): the detection observable must be reachable through the + // message read model on cold load so Console (#6) can consume it. + it('exposes extra.signatureLint through GET /api/messages (cold hydration)', async () => { + messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, + userId: 'default-user', + catId: 'opus', + content: 'unsigned final answer', + mentions: [], + timestamp: 2100, + threadId: 'thread-siglint', + extra: { signatureLint: { signed: false } }, + }); + + const res = await app.inject({ method: 'GET', url: '/api/messages?threadId=thread-siglint' }); + const body = JSON.parse(res.body); + + assert.equal(body.messages.length, 1); + assert.deepEqual(body.messages[0].extra?.signatureLint, { signed: false }); + }); + it('maps canonical system messages to type=system', async () => { messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'system', catId: 'system', content: '🐺 狼人请睁眼', @@ -113,6 +138,7 @@ describe('GET /api/messages', () => { it('returns persisted system error messages with catId=null as type=system', async () => { messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'default-user', catId: null, content: 'ping gemini', @@ -121,6 +147,7 @@ describe('GET /api/messages', () => { threadId: 'thread-1', }); messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, userId: 'system', catId: null, content: 'Error: stream_idle_stall: Gemini stopped responding', @@ -140,6 +167,7 @@ describe('GET /api/messages', () => { it('keeps persisted source-backed notices on the connector path even when userId=system', async () => { messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, userId: 'system', catId: null, content: '想交接给 @codex?把它单独放到新起一行开头,才能触发交接。', @@ -169,6 +197,7 @@ describe('GET /api/messages', () => { it('maps a2a_routing system messages to type=system with extra.systemKind', async () => { messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, userId: 'system', catId: null, content: '布偶猫 → 缅因猫', @@ -202,6 +231,7 @@ describe('GET /api/messages', () => { it('respects limit parameter', async () => { for (let i = 0; i < 10; i++) { messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'default-user', catId: null, content: `msg ${i}`, @@ -222,6 +252,7 @@ describe('GET /api/messages', () => { it('supports cursor pagination with before', async () => { for (let i = 0; i < 5; i++) { messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'default-user', catId: null, content: `msg ${i}`, @@ -246,6 +277,7 @@ describe('GET /api/messages', () => { // Insert 6 messages with distinct timestamps for (let i = 0; i < 6; i++) { messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'default-user', catId: null, content: `msg ${i}`, @@ -299,6 +331,7 @@ describe('GET /api/messages', () => { // All messages at the same timestamp (simulates burst writes) for (let i = 0; i < 4; i++) { messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'default-user', catId: null, content: `burst ${i}`, @@ -334,6 +367,7 @@ describe('GET /api/messages', () => { it('returns toolEvents when message has them (缅因猫 R2 P1-2)', async () => { messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'default-user', catId: 'opus', content: 'I read the file', @@ -359,6 +393,7 @@ describe('GET /api/messages', () => { it('omits toolEvents when message has none', async () => { messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'default-user', catId: 'opus', content: 'just text', @@ -374,6 +409,7 @@ describe('GET /api/messages', () => { it('preserves stream invocation identity for persisted assistant messages', async () => { messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'default-user', catId: 'opus', content: 'persisted stream bubble', @@ -395,6 +431,7 @@ describe('GET /api/messages', () => { it('filters by userId', async () => { messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'alice', catId: null, content: 'alice msg', @@ -402,6 +439,7 @@ describe('GET /api/messages', () => { timestamp: 1000, }); messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'bob', catId: null, content: 'bob msg', @@ -423,6 +461,7 @@ describe('GET /api/messages', () => { it('maps message with source field to type=connector', async () => { messageStore.append({ + provenance: { author: 'external_user', routed: false, observation: 'original' }, userId: 'default-user', catId: null, content: 'GitHub Review 通知', @@ -451,6 +490,7 @@ describe('GET /api/messages', () => { it('includes source.meta in API response (F098-C: needed for direction parsing)', async () => { messageStore.append({ + provenance: { author: 'external_user', routed: false, observation: 'original' }, userId: 'default-user', catId: null, content: 'review notification', @@ -480,6 +520,7 @@ describe('GET /api/messages', () => { it('serializes deliveredAt when present (F098-D P3 regression)', async () => { const stored = messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'default-user', catId: null, content: 'queued message', @@ -497,6 +538,7 @@ describe('GET /api/messages', () => { it('omits deliveredAt when not set (F098-D P3 regression)', async () => { messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'default-user', catId: null, content: 'immediate message', @@ -512,6 +554,7 @@ describe('GET /api/messages', () => { it('serializes extra.targetCats when present (F098-C1 regression)', async () => { messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'default-user', catId: 'opus', content: 'review done', @@ -533,6 +576,7 @@ describe('GET /api/messages', () => { it('message without source and without catId is type=user', async () => { messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'default-user', catId: null, content: 'normal user message', @@ -580,6 +624,7 @@ describe('GET /api/messages — summary NOT in timeline (clowder-ai#343)', () => it('summaries exist in store but do NOT appear in timeline', async () => { messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'default-user', catId: null, content: 'hello', @@ -587,6 +632,7 @@ describe('GET /api/messages — summary NOT in timeline (clowder-ai#343)', () => timestamp: 1000, }); messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'default-user', catId: 'opus', content: 'hi there', @@ -645,6 +691,7 @@ describe('GET /api/messages summary + pagination contract', () => { it('timeline does NOT inject summaries (clowder-ai#343)', async () => { for (let i = 0; i < 5; i++) { messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'default-user', catId: null, content: `msg ${i}`, @@ -845,6 +892,7 @@ describe('GET /api/messages internal message filtering', () => { it('filters context_briefing messages from API response', async () => { messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'default-user', catId: null, content: 'user msg', @@ -852,6 +900,7 @@ describe('GET /api/messages internal message filtering', () => { timestamp: 1000, }); messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, userId: 'system', catId: null, content: 'briefing nav', @@ -861,6 +910,7 @@ describe('GET /api/messages internal message filtering', () => { extra: { systemKind: 'context_briefing' }, }); messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'default-user', catId: 'opus', content: 'cat reply', @@ -877,6 +927,7 @@ describe('GET /api/messages internal message filtering', () => { it('filters routing-guard-failure connector messages from API response', async () => { messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'default-user', catId: null, content: 'user msg', @@ -884,6 +935,7 @@ describe('GET /api/messages internal message filtering', () => { timestamp: 1000, }); messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, userId: 'system', catId: null, content: 'route guard failed', @@ -900,6 +952,7 @@ describe('GET /api/messages internal message filtering', () => { it('preserves F233 duty briefing (origin=briefing without systemKind)', async () => { messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, userId: 'system', catId: null, content: 'duty briefing', @@ -922,6 +975,7 @@ describe('GET /api/messages internal message filtering', () => { // Oldest visible message messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, threadId, userId: 'default-user', catId: null, @@ -933,6 +987,7 @@ describe('GET /api/messages internal message filtering', () => { // 25 consecutive internal context_briefing messages for (let i = 0; i < 25; i++) { messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, threadId, userId: 'system', catId: null, @@ -946,6 +1001,7 @@ describe('GET /api/messages internal message filtering', () => { // Newest visible message messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, threadId, userId: 'default-user', catId: null, @@ -980,6 +1036,7 @@ describe('GET /api/messages internal message filtering', () => { // Only visible message — buried at the bottom behind the cluster messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, threadId, userId: 'default-user', catId: null, @@ -991,6 +1048,7 @@ describe('GET /api/messages internal message filtering', () => { // 300 consecutive internal context_briefing messages on top for (let i = 0; i < CLUSTER_SIZE; i++) { messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, threadId, userId: 'system', catId: null, @@ -1020,6 +1078,7 @@ describe('GET /api/messages internal message filtering', () => { const threadId = 'thread-exhausted'; messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, threadId, userId: 'default-user', catId: null, @@ -1031,6 +1090,7 @@ describe('GET /api/messages internal message filtering', () => { // A few internal messages after for (let i = 0; i < 3; i++) { messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, threadId, userId: 'system', catId: null, diff --git a/packages/api/test/messages-f108b-whisper-dispatch.test.js b/packages/api/test/messages-f108b-whisper-dispatch.test.js index 4f191254cd..9fe00571bf 100644 --- a/packages/api/test/messages-f108b-whisper-dispatch.test.js +++ b/packages/api/test/messages-f108b-whisper-dispatch.test.js @@ -31,6 +31,13 @@ function buildDeps(overrides = {}) { }, router: { resolveTargetsAndIntent: mock.fn(async () => ({ + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, targetCats: ['opus'], intent: { intent: 'execute' }, })), @@ -101,6 +108,13 @@ describe('F108B: whisper slot-aware delivery mode', () => { it('whisper to idle cat (codex) → immediate dispatch, not queued', async () => { // opus is busy, codex is idle. Whisper targets codex. deps.router.resolveTargetsAndIntent.mock.mockImplementation(async () => ({ + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, targetCats: ['codex'], intent: { intent: 'execute' }, })); @@ -135,6 +149,13 @@ describe('F108B: whisper slot-aware delivery mode', () => { it('whisper to busy cat (opus) → queued', async () => { // opus is busy. Whisper targets opus. deps.router.resolveTargetsAndIntent.mock.mockImplementation(async () => ({ + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, targetCats: ['opus'], intent: { intent: 'execute' }, })); @@ -178,6 +199,13 @@ describe('F108B: whisper slot-aware delivery mode', () => { // opus is busy, codex is idle. Message @mentions codex explicitly. // resolveTargetsAndIntent returns hasMentions: true because @codex was parsed. deps.router.resolveTargetsAndIntent.mock.mockImplementation(async () => ({ + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, targetCats: ['codex'], intent: { intent: 'execute' }, hasMentions: true, @@ -203,6 +231,13 @@ describe('F108B: whisper slot-aware delivery mode', () => { it('AC-B4: broadcast @mention to busy cat → queued', async () => { // opus is busy. Message @mentions opus explicitly. deps.router.resolveTargetsAndIntent.mock.mockImplementation(async () => ({ + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, targetCats: ['opus'], intent: { intent: 'execute' }, hasMentions: true, @@ -227,6 +262,13 @@ describe('F108B: whisper slot-aware delivery mode', () => { // opus is busy. No @mention → fallback routing resolves to opus. // hasMentions: false → thread-level check → queued. deps.router.resolveTargetsAndIntent.mock.mockImplementation(async () => ({ + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, targetCats: ['opus'], intent: { intent: 'execute' }, hasMentions: false, @@ -251,6 +293,13 @@ describe('F108B: whisper slot-aware delivery mode', () => { // @codex(idle) + @opus(busy). hasMentions: true, targetCats: ['codex', 'opus']. // Even though codex is idle, opus is busy → entire message should queue. deps.router.resolveTargetsAndIntent.mock.mockImplementation(async () => ({ + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, targetCats: ['codex', 'opus'], intent: { intent: 'execute' }, hasMentions: true, @@ -274,6 +323,13 @@ describe('F108B: whisper slot-aware delivery mode', () => { it('P1: multi @mention with reversed order (busy first) → queued', async () => { // @opus(busy) + @codex(idle). Order reversed — should still queue. deps.router.resolveTargetsAndIntent.mock.mockImplementation(async () => ({ + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, targetCats: ['opus', 'codex'], intent: { intent: 'execute' }, hasMentions: true, @@ -297,6 +353,13 @@ describe('F108B: whisper slot-aware delivery mode', () => { it('P1: multi @mention all idle → immediate', async () => { // @codex(idle) + @gemini(idle). Both idle → immediate. deps.router.resolveTargetsAndIntent.mock.mockImplementation(async () => ({ + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, targetCats: ['codex', 'gemini'], intent: { intent: 'execute' }, hasMentions: true, @@ -320,6 +383,13 @@ describe('F108B: whisper slot-aware delivery mode', () => { it('explicit deliveryMode=force on whisper → cancels target slot and executes', async () => { // opus is busy. Whisper to opus with force → should cancel and execute immediately. deps.router.resolveTargetsAndIntent.mock.mockImplementation(async () => ({ + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, targetCats: ['opus'], intent: { intent: 'execute' }, })); diff --git a/packages/api/test/messages-intent-mode.test.js b/packages/api/test/messages-intent-mode.test.js index c901c10576..8ab74c92c7 100644 --- a/packages/api/test/messages-intent-mode.test.js +++ b/packages/api/test/messages-intent-mode.test.js @@ -14,6 +14,13 @@ import Fastify from 'fastify'; function makeMockRouter(routeFn, routeExecutionFn) { return { resolveTargetsAndIntent: async () => ({ + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, targetCats: ['codex'], intent: { intent: 'execute', explicit: false, promptTags: [] }, }), diff --git a/packages/api/test/messages-parallel-slot-release.test.js b/packages/api/test/messages-parallel-slot-release.test.js index 585e719977..80bcff3eaf 100644 --- a/packages/api/test/messages-parallel-slot-release.test.js +++ b/packages/api/test/messages-parallel-slot-release.test.js @@ -83,6 +83,13 @@ describe('POST /api/messages parallel slot release', () => { const router = { resolveTargetsAndIntent: async () => ({ + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, targetCats: ['opus', 'codex'], intent: { intent: 'ideate', explicit: true, promptTags: [] }, }), @@ -172,6 +179,13 @@ describe('POST /api/messages parallel slot release', () => { const router = { resolveTargetsAndIntent: async () => ({ + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, targetCats: ['opus', 'codex'], intent: { intent: 'ideate', explicit: true, promptTags: [] }, }), diff --git a/packages/api/test/messages-sender-in-response.test.js b/packages/api/test/messages-sender-in-response.test.js index a6a7dd18cf..1268b0f755 100644 --- a/packages/api/test/messages-sender-in-response.test.js +++ b/packages/api/test/messages-sender-in-response.test.js @@ -27,6 +27,13 @@ function buildDeps(overrides = {}) { }, router: { resolveTargetsAndIntent: mock.fn(async () => ({ + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, targetCats: ['opus'], intent: { intent: 'execute' }, })), diff --git a/packages/api/test/opencode-mention-routing.test.js b/packages/api/test/opencode-mention-routing.test.js index 08f6a09364..12066c398f 100644 --- a/packages/api/test/opencode-mention-routing.test.js +++ b/packages/api/test/opencode-mention-routing.test.js @@ -20,10 +20,11 @@ ensureFakeCliOnPath('opencode'); // ── Shared fixtures ────────────────────────────────────────────── +// P1-4: @宪宪/@砚砚 removed from breeds → patterns aligned with cat-template.json /** Full pattern map including opencode — mirrors production catRegistry */ const allPatterns = new Map([ - ['opus', ['@opus', '@布偶猫', '@布偶', '@宪宪']], - ['codex', ['@codex', '@缅因猫', '@缅因', '@砚砚']], + ['opus', ['@opus', '@布偶猫', '@布偶', '@ragdoll']], + ['codex', ['@codex', '@缅因猫', '@缅因', '@maine']], ['gemini', ['@gemini', '@暹罗猫', '@暹罗', '@烁烁']], ['opencode', ['@opencode', '@金渐层', '@golden', '@golden-chinchilla']], ]); diff --git a/packages/api/test/pack-knowledge-scope.test.js b/packages/api/test/pack-knowledge-scope.test.js index 740305e005..a4f4abed94 100644 --- a/packages/api/test/pack-knowledge-scope.test.js +++ b/packages/api/test/pack-knowledge-scope.test.js @@ -186,7 +186,7 @@ describe('PackKnowledgeScope', () => { test('schema V6 migration adds pack_id column', async () => { const { CURRENT_SCHEMA_VERSION } = await import('../dist/domains/memory/schema.js'); - assert.equal(CURRENT_SCHEMA_VERSION, 26, 'Current schema version should be 26'); + assert.equal(CURRENT_SCHEMA_VERSION, 27, 'Current schema version should be 27'); // Create a store and check schema via its exposed db const { store } = await createTestStore(); @@ -199,6 +199,6 @@ describe('PackKnowledgeScope', () => { // Verify migration version const version = db.prepare('SELECT MAX(version) as v FROM schema_version').get(); - assert.equal(version.v, 26, 'Schema version should be 26'); + assert.equal(version.v, 27, 'Schema version should be 27'); }); }); diff --git a/packages/api/test/persistence-fault-drill.test.js b/packages/api/test/persistence-fault-drill.test.js index 94bbbf535e..cd244062af 100644 --- a/packages/api/test/persistence-fault-drill.test.js +++ b/packages/api/test/persistence-fault-drill.test.js @@ -48,6 +48,13 @@ function createFaultDrillRouter(modeRef) { return { async resolveTargetsAndIntent() { return { + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, targetCats: ['opus'], intent: { intent: 'execute', explicit: false, promptTags: [] }, }; diff --git a/packages/api/test/pingpong-reset.test.js b/packages/api/test/pingpong-reset.test.js index db86e992c2..980ee65d0f 100644 --- a/packages/api/test/pingpong-reset.test.js +++ b/packages/api/test/pingpong-reset.test.js @@ -31,7 +31,17 @@ function buildDeps(overrides = {}) { emitToUser: mock.fn(), }, router: { - resolveTargetsAndIntent: mock.fn(async () => ({ targetCats: ['opus'], intent: { intent: 'execute' } })), + resolveTargetsAndIntent: mock.fn(async () => ({ + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, + targetCats: ['opus'], + intent: { intent: 'execute' }, + })), routeExecution: mock.fn(async function* () { yield { type: 'done', catId: 'opus', timestamp: Date.now() }; }), diff --git a/packages/api/test/pipeline-prompt-builder.test.js b/packages/api/test/pipeline-prompt-builder.test.js index c5404d40d5..7b33c2741e 100644 --- a/packages/api/test/pipeline-prompt-builder.test.js +++ b/packages/api/test/pipeline-prompt-builder.test.js @@ -119,4 +119,58 @@ describe('PipelinePromptBuilder (AC-P2-6)', () => { assert.ok(output.length > 100, 'Works after reset'); assert.ok(ppb.getCachedRegistry() !== null, 'Re-initialized'); }); + + // -- AF-1 cold-start bootstrap regression (P2-2) ---------------------------- + + it('refreshOverrideSnapshot warms registry on cold start (AF-1 regression)', async () => { + // Simulates server restart: registry is null, store has existing overrides. + // Without bootstrap refreshOverrideSnapshot(), getCachedRegistry() stays null + // and all lifeline/override routes return 404. + ppb.resetPipelineSingleton(); + assert.equal(ppb.getCachedRegistry(), null, 'Cold: registry is null'); + + // Fake store: loadSnapshot returns a map with one disabled override + const fakeSnapshot = new Map([ + [ + 'test-hook', + { + hookId: 'test-hook', + enabled: false, + source: 'operator', + updatedAt: Date.now(), + updatedBy: 'test', + }, + ], + ]); + const fakeStore = { loadSnapshot: async () => fakeSnapshot }; + ppb.setOverrideStore(fakeStore); + + // This is the bootstrap call from index.ts — must warm registry from null + await ppb.refreshOverrideSnapshot(); + + assert.ok(ppb.getCachedRegistry() !== null, 'Warm: registry initialized by refreshOverrideSnapshot'); + // Verify the override snapshot was actually loaded into the registry + const registry = ppb.getCachedRegistry(); + assert.ok(registry.isEnabled !== undefined, 'Registry has isEnabled method'); + + // Clean up: restore singleton for any subsequent tests + ppb.resetPipelineSingleton(); + ppb.setOverrideStore(null); + }); + + // Source-contract: index.ts bootstrap must call refreshOverrideSnapshot() + // after setOverrideStore(). Without this, the helper test above passes but + // the actual server cold-starts with null registry. (R12 P2-2: "调用点 + helper 行为" 闭环) + it('index.ts bootstrap calls refreshOverrideSnapshot after setOverrideStore (AF-1 source contract)', async () => { + const { readFileSync } = await import('node:fs'); + const { resolve } = await import('node:path'); + const indexSrc = readFileSync(resolve(import.meta.dirname, '../src/index.ts'), 'utf-8'); + + // setOverrideStore must appear before refreshOverrideSnapshot in the source + const setStoreIdx = indexSrc.indexOf('setOverrideStore(hookOverrideStore)'); + const refreshIdx = indexSrc.indexOf('await refreshOverrideSnapshot()'); + assert.ok(setStoreIdx > 0, 'index.ts contains setOverrideStore(hookOverrideStore)'); + assert.ok(refreshIdx > 0, 'index.ts contains await refreshOverrideSnapshot()'); + assert.ok(refreshIdx > setStoreIdx, 'refreshOverrideSnapshot() comes after setOverrideStore()'); + }); }); diff --git a/packages/api/test/prompt-injection-enablement-matrix.test.js b/packages/api/test/prompt-injection-enablement-matrix.test.js new file mode 100644 index 0000000000..7167117ce0 --- /dev/null +++ b/packages/api/test/prompt-injection-enablement-matrix.test.js @@ -0,0 +1,245 @@ +// F257 Console 判据⑥ — Enablement matrix API contract tests. +// Verifies that manifest and content endpoints expose a two-plane matrix +// (localOverlay × runtimeOverride) derived from safetyTier, allowLocalOverride, +// disableable and actual storage state, so the Console shows consistent CTA +// states and blocked reasons. +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import Fastify from 'fastify'; +import { TEMPLATE_FILES } from '../dist/domains/cats/services/context/prompt-template-loader.js'; +import { promptInjectionRoutes } from '../dist/routes/prompt-injection.js'; +import { promptInjectionManifestRoutes } from '../dist/routes/prompt-injection-manifest.js'; + +const OWNER = 'test-owner'; +const LOCAL_WRITE_HEADERS = { + host: '127.0.0.1:3004', + origin: 'http://127.0.0.1:3003', +}; + +async function buildManifestApp(sessionUserId = OWNER) { + const app = Fastify(); + if (sessionUserId) { + app.addHook('onRequest', (req, _reply, done) => { + req.sessionUserId = sessionUserId; + done(); + }); + } + await app.register(promptInjectionManifestRoutes); + await app.ready(); + return app; +} + +async function buildContentApp(sessionUserId = OWNER) { + const app = Fastify(); + if (sessionUserId) { + app.addHook('onRequest', (req, _reply, done) => { + req.sessionUserId = sessionUserId; + done(); + }); + } + await app.register(promptInjectionRoutes); + await app.ready(); + return app; +} + +describe('prompt-injection enablement matrix (判据⑥)', () => { + before(() => { + process.env.DEFAULT_OWNER_USER_ID = OWNER; + }); + + it('manifest exposes enablementMatrix for every segment', async () => { + const app = await buildManifestApp(); + const res = await app.inject({ method: 'GET', url: '/api/prompt-injection/manifest' }); + assert.equal(res.statusCode, 200); + const body = res.json(); + assert.ok(Array.isArray(body.segments)); + assert.ok(body.segments.length > 0); + for (const segment of body.segments) { + assert.ok(segment.enablementMatrix, `segment ${segment.id} missing enablementMatrix`); + const m = segment.enablementMatrix; + assert.equal(m.segmentId, segment.id); + assert.equal(m.safetyTier, segment.safetyTier); + assert.equal(m.allowLocalOverride, segment.allowLocalOverride); + assert.equal(m.disableable, segment.disableable); + + // Two-plane contract + assert.ok(m.localOverlay, `segment ${segment.id} missing localOverlay`); + assert.ok(m.runtimeOverride, `segment ${segment.id} missing runtimeOverride`); + assert.ok(m.localOverlay.actions); + assert.ok(m.runtimeOverride.actions); + + for (const action of ['edit', 'restoreBackup', 'reset']) { + assert.ok( + Object.hasOwn(m.localOverlay.actions, action), + `segment ${segment.id} missing local action ${action}`, + ); + const perm = m.localOverlay.actions[action]; + assert.ok(Object.hasOwn(perm, 'allowed')); + assert.ok(Object.hasOwn(perm, 'reason')); + assert.ok(Object.hasOwn(perm, 'reasonCode')); + if (perm.allowed) { + assert.equal(perm.reason, null); + assert.equal(perm.reasonCode, null); + } else { + assert.ok(perm.reason, `segment ${segment.id} local action ${action} blocked without reason`); + assert.ok(perm.reasonCode, `segment ${segment.id} local action ${action} blocked without reasonCode`); + } + } + + for (const action of ['disable', 'enable', 'rollback', 'activateVersion']) { + assert.ok( + Object.hasOwn(m.runtimeOverride.actions, action), + `segment ${segment.id} missing runtime action ${action}`, + ); + const perm = m.runtimeOverride.actions[action]; + assert.ok(Object.hasOwn(perm, 'allowed')); + assert.ok(Object.hasOwn(perm, 'reason')); + assert.ok(Object.hasOwn(perm, 'reasonCode')); + if (perm.allowed) { + assert.equal(perm.reason, null); + assert.equal(perm.reasonCode, null); + } else { + assert.ok(perm.reason, `segment ${segment.id} runtime action ${action} blocked without reason`); + assert.ok(perm.reasonCode, `segment ${segment.id} runtime action ${action} blocked without reasonCode`); + } + } + } + await app.close(); + }); + + it('readonly + no-overlay segment blocks local edit with safety-tier reason', async () => { + const app = await buildManifestApp(); + const res = await app.inject({ method: 'GET', url: '/api/prompt-injection/manifest' }); + const { segments } = res.json(); + const s1 = segments.find((s) => s.id === 'S1'); + assert.ok(s1); + assert.equal(s1.safetyTier, 'readonly'); + assert.equal(s1.allowLocalOverride, false); + const edit = s1.enablementMatrix.localOverlay.actions.edit; + assert.equal(edit.allowed, false); + assert.equal(edit.reasonCode, 'safety-tier-readonly'); + const disable = s1.enablementMatrix.runtimeOverride.actions.disable; + assert.equal(disable.allowed, false); + assert.equal(disable.reasonCode, 'not-disableable'); + await app.close(); + }); + + it('editable + overlay + disableable segment allows edit and disable', async () => { + const app = await buildManifestApp(); + const res = await app.inject({ method: 'GET', url: '/api/prompt-injection/manifest' }); + const { segments } = res.json(); + const d10 = segments.find((s) => s.id === 'D10'); + assert.ok(d10); + assert.equal(d10.safetyTier, 'readonly'); + assert.equal(d10.allowLocalOverride, false); + assert.equal(d10.disableable, true); + // D10 is readonly in manifest, so edit is blocked; disable is allowed. + assert.equal(d10.enablementMatrix.localOverlay.actions.edit.allowed, false); + assert.equal(d10.enablementMatrix.runtimeOverride.actions.disable.allowed, true); + await app.close(); + }); + + it('content endpoint exposes enablementMatrix', async () => { + const app = await buildContentApp(); + const res = await app.inject({ method: 'GET', url: '/api/prompt-injection/segment/S6/content' }); + assert.equal(res.statusCode, 200); + const body = res.json(); + assert.ok(body.enablementMatrix); + assert.equal(body.enablementMatrix.segmentId, 'S6'); + assert.ok(body.enablementMatrix.localOverlay.actions.edit); + assert.ok(body.enablementMatrix.runtimeOverride.actions.disable); + await app.close(); + }); + + it('content endpoint uses hook manifest safetyTier (C1 editable, D1 readonly)', async () => { + const app = await buildContentApp(); + const c1 = await app.inject({ method: 'GET', url: '/api/prompt-injection/segment/C1/content' }); + assert.equal(c1.statusCode, 200); + const c1Body = c1.json(); + assert.equal(c1Body.enablementMatrix.safetyTier, 'editable'); + assert.equal(c1Body.enablementMatrix.localOverlay.actions.edit.allowed, true); + + const d1 = await app.inject({ method: 'GET', url: '/api/prompt-injection/segment/D1/content' }); + assert.equal(d1.statusCode, 200); + const d1Body = d1.json(); + assert.equal(d1Body.enablementMatrix.safetyTier, 'readonly'); + assert.equal(d1Body.enablementMatrix.localOverlay.actions.edit.allowed, false); + assert.equal(d1Body.enablementMatrix.localOverlay.actions.edit.reasonCode, 'safety-tier-readonly'); + await app.close(); + }); + + it('PUT /override rejects readonly segment on server side', async () => { + const app = await buildContentApp(OWNER); + const res = await app.inject({ + method: 'PUT', + url: '/api/prompt-injection/segment/D1/override', + headers: { ...LOCAL_WRITE_HEADERS, 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: 'edited' }), + }); + assert.equal(res.statusCode, 403); + const body = res.json(); + assert.match(body.error, /readonly/i); + await app.close(); + }); + + it('POST /restore-backup rejects readonly segment on server side', async () => { + const app = await buildContentApp(OWNER); + const res = await app.inject({ + method: 'POST', + url: '/api/prompt-injection/segment/D1/restore-backup', + headers: LOCAL_WRITE_HEADERS, + }); + assert.equal(res.statusCode, 403); + const body = res.json(); + assert.match(body.error, /readonly/i); + await app.close(); + }); + + it('PUT /override rejects safetyTier=readonly even when local overlay path exists', async () => { + const originalD1 = TEMPLATE_FILES.D1; + // D1 is readonly and normally has no local overlay path. Give it one so the + // only remaining blocker is the safetyTier gate. + TEMPLATE_FILES.D1 = { ...originalD1, local: 'd1-identity-anchor.local.md' }; + try { + const app = await buildContentApp(OWNER); + const res = await app.inject({ + method: 'PUT', + url: '/api/prompt-injection/segment/D1/override', + headers: { ...LOCAL_WRITE_HEADERS, 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: 'edited' }), + }); + assert.equal(res.statusCode, 403); + const body = res.json(); + assert.match(body.error, /readonly/i); + await app.close(); + } finally { + TEMPLATE_FILES.D1 = originalD1; + } + }); + + it('POST /restore-backup rejects safetyTier=readonly even when local overlay path exists', async () => { + const originalD1 = TEMPLATE_FILES.D1; + TEMPLATE_FILES.D1 = { ...originalD1, local: 'd1-identity-anchor.local.md' }; + try { + const app = await buildContentApp(OWNER); + const res = await app.inject({ + method: 'POST', + url: '/api/prompt-injection/segment/D1/restore-backup', + headers: LOCAL_WRITE_HEADERS, + }); + assert.equal(res.statusCode, 403); + const body = res.json(); + assert.match(body.error, /readonly/i); + await app.close(); + } finally { + TEMPLATE_FILES.D1 = originalD1; + } + }); + + it('401 when unauthenticated', async () => { + const app = await buildManifestApp(null); + const res = await app.inject({ method: 'GET', url: '/api/prompt-injection/manifest' }); + assert.equal(res.statusCode, 401); + await app.close(); + }); +}); diff --git a/packages/api/test/prompt-injection-variable-metadata.test.js b/packages/api/test/prompt-injection-variable-metadata.test.js new file mode 100644 index 0000000000..c6330cba79 --- /dev/null +++ b/packages/api/test/prompt-injection-variable-metadata.test.js @@ -0,0 +1,525 @@ +import assert from 'node:assert/strict'; +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { describe, it } from 'node:test'; +import Fastify from 'fastify'; +import { + getTemplateFileInfo, + getTemplateOverlayPath, + TEMPLATE_FILES, + TEMPLATES_DIR, +} from '../dist/domains/cats/services/context/prompt-template-loader.js'; +import { parseHookManifest } from '../dist/domains/prompt-hooks/hook-manifest-parser.js'; +import { promptInjectionRoutes } from '../dist/routes/prompt-injection.js'; + +const TEST_USER_ID = 'test-user'; +const AUTH_HEADERS = { 'x-cat-cafe-user': TEST_USER_ID }; +const LOCAL_WRITE_HEADERS = { + host: '127.0.0.1:3004', + origin: 'http://127.0.0.1:3003', +}; + +async function buildApp() { + const app = Fastify({ logger: false }); + await app.register(promptInjectionRoutes); + await app.ready(); + return app; +} + +async function buildSessionApp() { + const app = Fastify({ logger: false }); + app.addHook('onRequest', (req, _reply, done) => { + req.sessionUserId = TEST_USER_ID; + done(); + }); + await app.register(promptInjectionRoutes); + await app.ready(); + return app; +} + +async function withDefaultOwnerUserId(value, fn) { + const prev = process.env.DEFAULT_OWNER_USER_ID; + if (value === null) delete process.env.DEFAULT_OWNER_USER_ID; + else process.env.DEFAULT_OWNER_USER_ID = value; + try { + return await fn(); + } finally { + if (prev === undefined) delete process.env.DEFAULT_OWNER_USER_ID; + else process.env.DEFAULT_OWNER_USER_ID = prev; + } +} + +function snapshotFile(path) { + return existsSync(path) ? readFileSync(path, 'utf-8') : null; +} + +function restoreFile(path, content) { + if (content === null) { + if (existsSync(path)) unlinkSync(path); + return; + } + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content, 'utf-8'); +} + +async function withPreservedOverlay(segmentId, fn) { + const fileInfo = getTemplateFileInfo(segmentId); + assert.ok(fileInfo?.local, `${segmentId} should have a local overlay path`); + const localPath = getTemplateOverlayPath(segmentId); + assert.ok(localPath, `${segmentId} should resolve a writable overlay path`); + const bakPath = `${localPath}.bak`; + const assetLocalPath = join(TEMPLATES_DIR, fileInfo.local); + const assetBakPath = `${assetLocalPath}.bak`; + const localSnapshot = snapshotFile(localPath); + const bakSnapshot = snapshotFile(bakPath); + const assetLocalSnapshot = snapshotFile(assetLocalPath); + const assetBakSnapshot = snapshotFile(assetBakPath); + try { + await fn(); + } finally { + restoreFile(localPath, localSnapshot); + restoreFile(bakPath, bakSnapshot); + restoreFile(assetLocalPath, assetLocalSnapshot); + restoreFile(assetBakPath, assetBakSnapshot); + } +} + +describe('prompt-injection variable metadata', () => { + describe('GET /api/prompt-injection/segment/:id/content', () => { + it('returns templateRef and variableDefs for a template-backed segment', async () => { + const app = await buildApp(); + try { + const res = await app.inject({ + method: 'GET', + url: '/api/prompt-injection/segment/S4/content', + headers: AUTH_HEADERS, + }); + assert.equal(res.statusCode, 200, `expected 200, got ${res.statusCode}: ${res.body}`); + const body = JSON.parse(res.body); + assert.equal(body.segmentId, 'S4'); + assert.equal(body.templateRef, 's4-collaboration.md'); + assert.ok(Array.isArray(body.variableDefs), 'variableDefs should be an array'); + const varDef = body.variableDefs.find((v) => v.name === 'CALLABLE_MENTIONS'); + assert.ok(varDef, 'CALLABLE_MENTIONS variable def should exist'); + assert.ok(varDef.description && varDef.description.length > 0, 'description should be present'); + assert.ok(body.content.includes('{{CALLABLE_MENTIONS}}'), 'content should retain placeholder'); + } finally { + await app.close(); + } + }); + + it('returns templateRef and variableDefs for a hook-registered segment', async () => { + const app = await buildApp(); + try { + const res = await app.inject({ + method: 'GET', + url: '/api/prompt-injection/segment/S1/content', + headers: AUTH_HEADERS, + }); + assert.equal(res.statusCode, 200, `expected 200, got ${res.statusCode}: ${res.body}`); + const body = JSON.parse(res.body); + assert.equal(body.segmentId, 'S1'); + assert.equal(body.templateRef, 's1-identity.md'); + assert.ok(Array.isArray(body.variableDefs)); + } finally { + await app.close(); + } + }); + + it('returns variableDefs from TEMPLATE_FILES registry for non-hook template-backed segments', async () => { + const app = await buildApp(); + try { + const res = await app.inject({ + method: 'GET', + url: '/api/prompt-injection/segment/M1/content', + headers: AUTH_HEADERS, + }); + assert.equal(res.statusCode, 200, `expected 200, got ${res.statusCode}: ${res.body}`); + const body = JSON.parse(res.body); + assert.equal(body.segmentId, 'M1'); + assert.ok(Array.isArray(body.variableDefs)); + const missionDef = body.variableDefs.find((v) => v.name === 'MISSION'); + assert.ok(missionDef, 'MISSION variable def should come from TEMPLATE_FILES registry'); + assert.ok(missionDef.description && missionDef.description.length > 0, 'description should be present'); + } finally { + await app.close(); + } + }); + + it('returns empty variableDefs for segments without variable metadata', async () => { + const app = await buildApp(); + try { + const res = await app.inject({ + method: 'GET', + url: '/api/prompt-injection/segment/D8/content', + headers: AUTH_HEADERS, + }); + assert.equal(res.statusCode, 200, `expected 200, got ${res.statusCode}: ${res.body}`); + const body = JSON.parse(res.body); + assert.equal(body.segmentId, 'D8'); + assert.deepEqual(body.variableDefs, []); + } finally { + await app.close(); + } + }); + + it('preserves source placeholders in content (not expanded)', async () => { + const app = await buildApp(); + try { + const res = await app.inject({ + method: 'GET', + url: '/api/prompt-injection/segment/S13/content', + headers: AUTH_HEADERS, + }); + assert.equal(res.statusCode, 200, `expected 200, got ${res.statusCode}: ${res.body}`); + const body = JSON.parse(res.body); + assert.equal(body.segmentId, 'S13'); + assert.ok(body.content.includes('{{RICH_BLOCK_SHORT}}'), 'content should contain RICH_BLOCK_SHORT placeholder'); + } finally { + await app.close(); + } + }); + }); + + describe('PUT /api/prompt-injection/segment/:id/override', () => { + it('saves source with placeholders and rejects expanded runtime value in payload', async () => { + await withDefaultOwnerUserId(TEST_USER_ID, async () => { + await withPreservedOverlay('S13', async () => { + const app = await buildSessionApp(); + try { + // Raw source must retain HTML comment bytes; stripping is a UI preview concern only. + const sourceWithPlaceholder = + '\nRich block short: {{RICH_BLOCK_SHORT}}'; + const expandedValue = 'Rich block short: '; + + const saveRes = await app.inject({ + method: 'PUT', + url: '/api/prompt-injection/segment/S13/override', + headers: LOCAL_WRITE_HEADERS, + payload: { content: sourceWithPlaceholder }, + }); + assert.equal(saveRes.statusCode, 200, `expected 200, got ${saveRes.statusCode}: ${saveRes.body}`); + + // Now verify GET still returns the source with placeholder and comment bytes + const getRes = await app.inject({ + method: 'GET', + url: '/api/prompt-injection/segment/S13/content', + headers: AUTH_HEADERS, + }); + const body = JSON.parse(getRes.body); + assert.ok(body.content.includes('{{RICH_BLOCK_SHORT}}'), 'saved content should retain placeholder'); + assert.ok(body.content.includes(''), 'saved content should retain HTML comment bytes'); + + // Expanded value should not be persisted as override + const badSaveRes = await app.inject({ + method: 'PUT', + url: '/api/prompt-injection/segment/S13/override', + headers: LOCAL_WRITE_HEADERS, + payload: { content: expandedValue }, + }); + assert.equal( + badSaveRes.statusCode, + 400, + `expected 400 for expanded value, got ${badSaveRes.statusCode}: ${badSaveRes.body}`, + ); + } finally { + await app.close(); + } + }); + }); + }); + + it('rejects a legacy expanded overlay without placeholders and allows recovery with canonical source', async () => { + await withDefaultOwnerUserId(TEST_USER_ID, async () => { + await withPreservedOverlay('S13', async () => { + const app = await buildSessionApp(); + try { + const localPath = getTemplateOverlayPath('S13'); + assert.ok(localPath); + // Simulate a legacy overlay that already contains an expanded runtime value. + const expandedOverlay = 'Rich block short: '; + writeFileSync(localPath, expandedOverlay, 'utf-8'); + + // Re-saving the expanded value must be rejected against the canonical base template. + const badRes = await app.inject({ + method: 'PUT', + url: '/api/prompt-injection/segment/S13/override', + headers: LOCAL_WRITE_HEADERS, + payload: { content: expandedOverlay }, + }); + assert.equal(badRes.statusCode, 400, `expected 400, got ${badRes.statusCode}: ${badRes.body}`); + + // Recovery: saving canonical source with the required placeholder succeeds. + const canonicalSource = '\nRich block short: {{RICH_BLOCK_SHORT}}'; + const goodRes = await app.inject({ + method: 'PUT', + url: '/api/prompt-injection/segment/S13/override', + headers: LOCAL_WRITE_HEADERS, + payload: { content: canonicalSource }, + }); + assert.equal(goodRes.statusCode, 200, `expected 200, got ${goodRes.statusCode}: ${goodRes.body}`); + + const getRes = await app.inject({ + method: 'GET', + url: '/api/prompt-injection/segment/S13/content', + headers: AUTH_HEADERS, + }); + const body = JSON.parse(getRes.body); + assert.ok(body.content.includes('{{RICH_BLOCK_SHORT}}'), 'recovered content should retain placeholder'); + } finally { + await app.close(); + } + }); + }); + }); + + it('rejects restore-backup when .bak contains expanded runtime values', async () => { + await withDefaultOwnerUserId(TEST_USER_ID, async () => { + await withPreservedOverlay('S13', async () => { + const app = await buildSessionApp(); + try { + const localPath = getTemplateOverlayPath('S13'); + assert.ok(localPath); + const canonicalSource = 'Rich block short: {{RICH_BLOCK_SHORT}}'; + const expandedBackup = 'Rich block short: '; + + // Save canonical source so a .bak file is created on the next save. + await app.inject({ + method: 'PUT', + url: '/api/prompt-injection/segment/S13/override', + headers: LOCAL_WRITE_HEADERS, + payload: { content: canonicalSource }, + }); + + // Overwrite .bak with a legacy expanded value. + writeFileSync(`${localPath}.bak`, expandedBackup, 'utf-8'); + + // Restore must reject the expanded backup against the immutable base template. + const restoreRes = await app.inject({ + method: 'POST', + url: '/api/prompt-injection/segment/S13/restore-backup', + headers: LOCAL_WRITE_HEADERS, + }); + assert.equal(restoreRes.statusCode, 400, `expected 400, got ${restoreRes.statusCode}: ${restoreRes.body}`); + + // Current overlay must remain canonical. + const getRes = await app.inject({ + method: 'GET', + url: '/api/prompt-injection/segment/S13/content', + headers: AUTH_HEADERS, + }); + const body = JSON.parse(getRes.body); + assert.ok(body.content.includes('{{RICH_BLOCK_SHORT}}'), 'overlay should still contain placeholder'); + } finally { + await app.close(); + } + }); + }); + }); + }); + + describe('hook-manifest-parser variables', () => { + it('accepts valid variables array', () => { + const tmpDir = `/tmp/f257-parser-test-${process.hrtime.bigint()}`; + mkdirSync(tmpDir, { recursive: true }); + const yamlPath = join(tmpDir, 'hook.yaml'); + writeFileSync( + yamlPath, + `id: T1 +name: Test +stage: session-init +order: 100 +version: 1 +enabled: true +disableable: false +safetyTier: readonly +transparencyTier: visible-by-default +governanceTier: immutable +template: test.md +inputs: [] +variables: + - name: FOO + description: foo desc + placeholder: foo-value +`, + ); + try { + const result = parseHookManifest(yamlPath); + assert.ok(result.ok, `parser should accept valid variables: ${result.errors.join('; ')}`); + assert.equal(result.manifest.variables.length, 1); + assert.equal(result.manifest.variables[0].name, 'FOO'); + assert.equal(result.manifest.variables[0].description, 'foo desc'); + assert.equal(result.manifest.variables[0].placeholder, 'foo-value'); + } finally { + unlinkSync(yamlPath); + } + }); + + it('rejects variable missing name', () => { + const tmpDir = `/tmp/f257-parser-test-${process.hrtime.bigint()}`; + mkdirSync(tmpDir, { recursive: true }); + const yamlPath = join(tmpDir, 'hook.yaml'); + writeFileSync( + yamlPath, + `id: T1 +name: Test +stage: session-init +order: 100 +version: 1 +enabled: true +disableable: false +safetyTier: readonly +transparencyTier: visible-by-default +governanceTier: immutable +template: test.md +inputs: [] +variables: + - description: no name +`, + ); + try { + const result = parseHookManifest(yamlPath); + assert.equal(result.ok, false); + assert.ok(result.errors.some((e) => /variables.*name/i.test(e))); + } finally { + unlinkSync(yamlPath); + } + }); + + it('rejects variable with non-string description', () => { + const tmpDir = `/tmp/f257-parser-test-${process.hrtime.bigint()}`; + mkdirSync(tmpDir, { recursive: true }); + const yamlPath = join(tmpDir, 'hook.yaml'); + writeFileSync( + yamlPath, + `id: T1 +name: Test +stage: session-init +order: 100 +version: 1 +enabled: true +disableable: false +safetyTier: readonly +transparencyTier: visible-by-default +governanceTier: immutable +template: test.md +inputs: [] +variables: + - name: FOO + description: 42 +`, + ); + try { + const result = parseHookManifest(yamlPath); + assert.equal(result.ok, false); + assert.ok(result.errors.some((e) => /variables.*description/i.test(e))); + } finally { + unlinkSync(yamlPath); + } + }); + }); + + function collectDuplicates(defNames, id, duplicate) { + const seen = new Set(); + for (const name of defNames) { + if (seen.has(name)) duplicate.push({ id, name }); + seen.add(name); + } + } + + function collectEmptyDescriptions(variableDefs, id, emptyDesc) { + for (const v of variableDefs ?? []) { + if (!v.description || v.description.trim().length === 0) { + emptyDesc.push({ id, name: v.name }); + } + } + } + + function collectMissingAndExtra(placeholderSet, defSet, id, missing, extra) { + for (const name of placeholderSet) { + if (!defSet.has(name)) missing.push({ id, name }); + } + for (const name of defSet) { + if (!placeholderSet.has(name)) extra.push({ id, name }); + } + } + + async function fetchSegmentContent(app, id) { + const res = await app.inject({ + method: 'GET', + url: `/api/prompt-injection/segment/${id}/content`, + headers: AUTH_HEADERS, + }); + assert.equal(res.statusCode, 200, `expected 200 for ${id}, got ${res.statusCode}: ${res.body}`); + return JSON.parse(res.body); + } + + function classifySegment(body) { + const placeholderSet = new Set(body.vars ?? []); + const defNames = (body.variableDefs ?? []).map((v) => v.name); + const defSet = new Set(defNames); + return { placeholderSet, defNames, defSet, hasPlaceholders: placeholderSet.size > 0 }; + } + + async function runParityCensus(app) { + const missing = []; + const extra = []; + const duplicate = []; + const emptyDesc = []; + let placeholderCount = 0; + + for (const id of Object.keys(TEMPLATE_FILES)) { + const body = await fetchSegmentContent(app, id); + const { placeholderSet, defNames, defSet, hasPlaceholders } = classifySegment(body); + if (hasPlaceholders) placeholderCount++; + collectDuplicates(defNames, id, duplicate); + collectEmptyDescriptions(body.variableDefs, id, emptyDesc); + collectMissingAndExtra(placeholderSet, defSet, id, missing, extra); + } + + return { missing, extra, duplicate, emptyDesc, placeholderCount }; + } + + describe('TEMPLATE_FILES variable metadata parity', () => { + it('placeholder names and definition names are exactly equal for every segment (fail-closed)', async () => { + const app = await buildApp(); + try { + const { missing, extra, duplicate, emptyDesc, placeholderCount } = await runParityCensus(app); + const total = Object.keys(TEMPLATE_FILES).length; + assert.equal(total, 50, `production resolver census: total=${total}`); + assert.equal( + placeholderCount, + 36, + `production resolver census: placeholder-bearing=${placeholderCount}, non-placeholder=${total - placeholderCount}`, + ); + assert.deepEqual(missing, [], 'every placeholder must have a definition'); + assert.deepEqual(extra, [], 'every definition must correspond to a placeholder (no extras)'); + assert.deepEqual(duplicate, [], 'variable definitions must not contain duplicate names'); + assert.deepEqual(emptyDesc, [], 'all variable definitions must have non-empty descriptions'); + } finally { + await app.close(); + } + }); + + it('rejects an extra variable definition injected into TEMPLATE_FILES (GHOST_VAR regression)', async () => { + const original = (TEMPLATE_FILES.M1.variables ?? []).slice(); + TEMPLATE_FILES.M1.variables = [ + ...(TEMPLATE_FILES.M1.variables ?? []), + { name: 'GHOST_VAR', description: 'should not exist', placeholder: 'ghost' }, + ]; + try { + const app = await buildApp(); + try { + const body = await fetchSegmentContent(app, 'M1'); + const placeholderSet = new Set(body.vars ?? []); + const extra = (body.variableDefs ?? []).map((v) => v.name).filter((name) => !placeholderSet.has(name)); + assert.deepEqual(extra, ['GHOST_VAR'], 'extra definition should be detected by exact parity'); + } finally { + await app.close(); + } + } finally { + TEMPLATE_FILES.M1.variables = original; + } + }); + }); +}); diff --git a/packages/api/test/prompt-segments-eval-domain.test.js b/packages/api/test/prompt-segments-eval-domain.test.js new file mode 100644 index 0000000000..3f5d91fc98 --- /dev/null +++ b/packages/api/test/prompt-segments-eval-domain.test.js @@ -0,0 +1,187 @@ +/** + * F257 Phase A Line B — eval:harness-ledger domain registration tests + * + * Verifies: prompt-segments in KNOWN_SOURCE_REFS_KINDS, discriminator, + * structural validator, inferSourceRefsKind dispatch. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +const { isKnownSourceRefsKind, isPromptSegmentsSourceRefs, validatePromptSegmentsSelector, inferSourceRefsKind } = + await import('../dist/infrastructure/harness-eval/publish-verdict/validation.js'); + +describe('eval:harness-ledger domain registration', () => { + describe('KNOWN_SOURCE_REFS_KINDS', () => { + test('includes prompt-segments', () => { + assert.ok(isKnownSourceRefsKind('prompt-segments')); + }); + }); + + describe('isPromptSegmentsSourceRefs', () => { + test('returns true for prompt-segments kind', () => { + assert.ok( + isPromptSegmentsSourceRefs({ + kind: 'prompt-segments', + windowStartMs: 0, + windowEndMs: 1, + evalRunId: 'hlr-1234567890-abcdef12', + }), + ); + }); + + test('returns false for undefined', () => { + assert.ok(!isPromptSegmentsSourceRefs(undefined)); + }); + + test('returns false for other kinds', () => { + assert.ok( + !isPromptSegmentsSourceRefs({ + kind: 'qc-metrics-rollup', + windowStartMs: 0, + windowEndMs: 1, + evalRunId: 'hlr-1234567890-abcdef12', + }), + ); + }); + + test('returns false for objects without kind', () => { + assert.ok(!isPromptSegmentsSourceRefs({ windowStartMs: 0, windowEndMs: 1 })); + }); + }); + + describe('validatePromptSegmentsSelector', () => { + test('accepts valid selector', () => { + const result = validatePromptSegmentsSelector({ + kind: 'prompt-segments', + windowStartMs: 1000, + windowEndMs: 2000, + evalRunId: 'hlr-1234567890-abcdef12', + }); + assert.equal(result, null); + }); + + test('accepts valid selector with guardId', () => { + const result = validatePromptSegmentsSelector({ + kind: 'prompt-segments', + windowStartMs: 1000, + windowEndMs: 2000, + guardId: 'hold_ball_rate_limit', + evalRunId: 'hlr-1234567890-abcdef12', + }); + assert.equal(result, null); + }); + + test('rejects wrong kind', () => { + const result = validatePromptSegmentsSelector({ + kind: 'wrong-kind', + windowStartMs: 1000, + windowEndMs: 2000, + evalRunId: 'hlr-1234567890-abcdef12', + }); + assert.ok(result); + assert.match(result, /expected kind='prompt-segments'/); + }); + + test('rejects non-finite windowStartMs', () => { + const result = validatePromptSegmentsSelector({ + kind: 'prompt-segments', + windowStartMs: Number.POSITIVE_INFINITY, + windowEndMs: 2000, + evalRunId: 'hlr-1234567890-abcdef12', + }); + assert.ok(result); + assert.match(result, /windowStartMs must be a finite number/); + }); + + test('rejects non-finite windowEndMs', () => { + const result = validatePromptSegmentsSelector({ + kind: 'prompt-segments', + windowStartMs: 1000, + windowEndMs: Number.NaN, + evalRunId: 'hlr-1234567890-abcdef12', + }); + assert.ok(result); + assert.match(result, /windowEndMs must be a finite number/); + }); + + test('rejects windowEndMs <= windowStartMs', () => { + const result = validatePromptSegmentsSelector({ + kind: 'prompt-segments', + windowStartMs: 2000, + windowEndMs: 1000, + evalRunId: 'hlr-1234567890-abcdef12', + }); + assert.ok(result); + assert.match(result, /windowEndMs must be greater than windowStartMs/); + }); + + test('rejects empty guardId', () => { + const result = validatePromptSegmentsSelector({ + kind: 'prompt-segments', + windowStartMs: 1000, + windowEndMs: 2000, + guardId: '', + evalRunId: 'hlr-1234567890-abcdef12', + }); + assert.ok(result); + assert.match(result, /guardId must be a non-empty string/); + }); + + test('rejects guardId with newlines', () => { + const result = validatePromptSegmentsSelector({ + kind: 'prompt-segments', + windowStartMs: 1000, + windowEndMs: 2000, + guardId: 'guard\ninjection', + evalRunId: 'hlr-1234567890-abcdef12', + }); + assert.ok(result); + assert.match(result, /guardId must not contain newlines/); + }); + + test('rejects missing evalRunId', () => { + const result = validatePromptSegmentsSelector({ + kind: 'prompt-segments', + windowStartMs: 1000, + windowEndMs: 2000, + }); + assert.ok(result); + assert.match(result, /evalRunId is required/); + }); + + test('rejects malformed evalRunId', () => { + const result = validatePromptSegmentsSelector({ + kind: 'prompt-segments', + windowStartMs: 1000, + windowEndMs: 2000, + evalRunId: 'not-a-valid-id', + }); + assert.ok(result); + assert.match(result, /evalRunId must match generator format/); + }); + }); + + describe('inferSourceRefsKind', () => { + test('infers prompt-segments from PromptSegmentsSourceSelector', () => { + const kind = inferSourceRefsKind({ + kind: 'prompt-segments', + windowStartMs: 1000, + windowEndMs: 2000, + evalRunId: 'hlr-1234567890-abcdef12', + }); + assert.equal(kind, 'prompt-segments'); + }); + + test('does not misclassify prompt-segments as a2a', () => { + // prompt-segments has explicit kind — must NOT fall through to a2a default + const kind = inferSourceRefsKind({ + kind: 'prompt-segments', + windowStartMs: 1000, + windowEndMs: 2000, + evalRunId: 'hlr-1234567890-abcdef12', + }); + assert.notEqual(kind, 'a2a-snapshot-attribution'); + }); + }); +}); diff --git a/packages/api/test/proposal-approve-dispatch.test.js b/packages/api/test/proposal-approve-dispatch.test.js index d7339751d2..c61d6a20a2 100644 --- a/packages/api/test/proposal-approve-dispatch.test.js +++ b/packages/api/test/proposal-approve-dispatch.test.js @@ -30,7 +30,18 @@ describe('F128 approve dispatch — initialMessage routing', () => { const router = { async resolveTargetsAndIntent(content, threadId, options) { resolveCalls.push({ content, threadId, options }); - return { targetCats: ['opus'], intent: { intent: 'execute' }, hasMentions: false }; + return { + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, + targetCats: ['opus'], + intent: { intent: 'execute' }, + hasMentions: false, + }; }, }; const queueProcessor = { @@ -104,7 +115,18 @@ describe('F128 approve dispatch — initialMessage routing', () => { const router = { async resolveTargetsAndIntent() { // Simulate the real router behaviour for a no-@-mention message: 0 targets. - return { targetCats: [], intent: { intent: 'execute' }, hasMentions: false }; + return { + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, + targetCats: [], + intent: { intent: 'execute' }, + hasMentions: false, + }; }, }; const processCalls = []; @@ -160,7 +182,18 @@ describe('F128 approve dispatch — initialMessage routing', () => { const invocationQueue = new InvocationQueue(); const router = { async resolveTargetsAndIntent() { - return { targetCats: [], intent: { intent: 'ideate' }, hasMentions: false }; + return { + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, + targetCats: [], + intent: { intent: 'ideate' }, + hasMentions: false, + }; }, }; const queueProcessor = { @@ -203,7 +236,18 @@ describe('F128 approve dispatch — initialMessage routing', () => { const invocationQueue = new InvocationQueue(); const router = { async resolveTargetsAndIntent() { - return { targetCats: ['opus'], intent: { intent: 'execute' }, hasMentions: false }; + return { + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, + targetCats: ['opus'], + intent: { intent: 'execute' }, + hasMentions: false, + }; }, }; const queueProcessor = { @@ -282,7 +326,18 @@ describe('F128 approve dispatch — initialMessage routing', () => { const invocationQueue = new InvocationQueue(); const router = { async resolveTargetsAndIntent() { - return { targetCats: ['codex'], intent: { intent: 'execute' }, hasMentions: true }; + return { + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, + targetCats: ['codex'], + intent: { intent: 'execute' }, + hasMentions: true, + }; }, }; const queueProcessor = { diff --git a/packages/api/test/proposal-chain-protocol.test.js b/packages/api/test/proposal-chain-protocol.test.js index ae56ed54ce..6fe3be3dab 100644 --- a/packages/api/test/proposal-chain-protocol.test.js +++ b/packages/api/test/proposal-chain-protocol.test.js @@ -21,7 +21,18 @@ describe('F128 chain protocol injection', () => { const invocationQueue = new InvocationQueue(); const router = { async resolveTargetsAndIntent() { - return { targetCats: [], intent: { intent: 'execute' }, hasMentions: false }; + return { + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, + targetCats: [], + intent: { intent: 'execute' }, + hasMentions: false, + }; }, }; const queueProcessor = { @@ -95,7 +106,18 @@ describe('F128 chain protocol injection', () => { const invocationQueue = new InvocationQueue(); const router = { async resolveTargetsAndIntent() { - return { targetCats: [], intent: { intent: 'execute' }, hasMentions: false }; + return { + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, + targetCats: [], + intent: { intent: 'execute' }, + hasMentions: false, + }; }, }; const queueProcessor = { @@ -180,6 +202,14 @@ describe('F128 chain protocol injection', () => { targetCats, intent: { intent: 'execute' }, hasMentions: targetCats.length > 0, + // real-router contract: a parser ALWAYS hands over its batch (zero attempts on no @) + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, }; }, }; @@ -256,7 +286,18 @@ describe('F128 chain protocol injection', () => { const invocationQueue = new InvocationQueue(); const router = { async resolveTargetsAndIntent() { - return { targetCats: ['opus'], intent: { intent: 'execute' }, hasMentions: true }; + return { + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, + targetCats: ['opus'], + intent: { intent: 'execute' }, + hasMentions: true, + }; }, }; const queueProcessor = { diff --git a/packages/api/test/proposal-explicit-intent.test.js b/packages/api/test/proposal-explicit-intent.test.js index 4a04887aa4..8767208550 100644 --- a/packages/api/test/proposal-explicit-intent.test.js +++ b/packages/api/test/proposal-explicit-intent.test.js @@ -42,7 +42,18 @@ describe('F128 explicit intent override (round-5)', () => { const invocationQueue = new InvocationQueue(); const router = { async resolveTargetsAndIntent() { - return { targetCats: [], intent: { intent: 'ideate' }, hasMentions: false }; + return { + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, + targetCats: [], + intent: { intent: 'ideate' }, + hasMentions: false, + }; }, }; const queueProcessor = { @@ -141,7 +152,18 @@ describe('F128 explicit intent override (round-5)', () => { async resolveTargetsAndIntent() { // Simulate real router: raw `#execute @kimi @gemini @codex` → // resolved.targetCats = [kimi, gemini, codex]. - return { targetCats: ['kimi', 'gemini', 'codex'], intent: { intent: 'execute' }, hasMentions: true }; + return { + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, + targetCats: ['kimi', 'gemini', 'codex'], + intent: { intent: 'execute' }, + hasMentions: true, + }; }, }; const queueProcessor = { diff --git a/packages/api/test/proposal-phase-aa.test.js b/packages/api/test/proposal-phase-aa.test.js index 0edc49579d..3f119295a4 100644 --- a/packages/api/test/proposal-phase-aa.test.js +++ b/packages/api/test/proposal-phase-aa.test.js @@ -23,7 +23,18 @@ describe('F128 Phase AA — seed message source attribution', () => { const invocationQueue = new InvocationQueue(); const router = { async resolveTargetsAndIntent() { - return { targetCats: ['opus'], intent: { intent: 'execute' }, hasMentions: false }; + return { + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, + targetCats: ['opus'], + intent: { intent: 'execute' }, + hasMentions: false, + }; }, }; const queueProcessor = { @@ -70,7 +81,18 @@ describe('F128 Phase AA — seed message source attribution', () => { const invocationQueue = new InvocationQueue(); const router = { async resolveTargetsAndIntent() { - return { targetCats: ['opus'], intent: { intent: 'execute' }, hasMentions: false }; + return { + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, + targetCats: ['opus'], + intent: { intent: 'execute' }, + hasMentions: false, + }; }, }; const queueProcessor = { diff --git a/packages/api/test/proposal-reporter-handle.test.js b/packages/api/test/proposal-reporter-handle.test.js index fafaac7f6b..ada1180928 100644 --- a/packages/api/test/proposal-reporter-handle.test.js +++ b/packages/api/test/proposal-reporter-handle.test.js @@ -41,7 +41,18 @@ describe('F128 parallel reporter handle resolution', () => { const invocationQueue = new InvocationQueue(); const router = { async resolveTargetsAndIntent() { - return { targetCats: ['kimi', 'gemini', 'codex'], intent: { intent: 'ideate' }, hasMentions: true }; + return { + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, + targetCats: ['kimi', 'gemini', 'codex'], + intent: { intent: 'ideate' }, + hasMentions: true, + }; }, }; const queueProcessor = { @@ -97,7 +108,18 @@ describe('F128 parallel reporter handle resolution', () => { const router = { async resolveTargetsAndIntent() { // Router resolves Chinese alias `@砚砚` → catId `codex` per cat-template.json. - return { targetCats: ['codex', 'opus'], intent: { intent: 'ideate' }, hasMentions: true }; + return { + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, + targetCats: ['codex', 'opus'], + intent: { intent: 'ideate' }, + hasMentions: true, + }; }, }; const queueProcessor = { @@ -146,7 +168,18 @@ describe('F128 parallel reporter handle resolution', () => { const invocationQueue = new InvocationQueue(); const router = { async resolveTargetsAndIntent() { - return { targetCats: ['gpt-5.2', 'gpt-5.4'], intent: { intent: 'ideate' }, hasMentions: true }; + return { + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, + targetCats: ['gpt-5.2', 'gpt-5.4'], + intent: { intent: 'ideate' }, + hasMentions: true, + }; }, }; const queueProcessor = { diff --git a/packages/api/test/proposal-resilience.test.js b/packages/api/test/proposal-resilience.test.js index acbf58f4dd..a5aea58468 100644 --- a/packages/api/test/proposal-resilience.test.js +++ b/packages/api/test/proposal-resilience.test.js @@ -65,6 +65,7 @@ describe('F128 partial-commit + dedup + self-heal', () => { assert.equal(first.statusCode, 200); for (let i = 0; i < 60; i++) { await ctx.messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'alice', catId: null, content: `filler ${i}`, diff --git a/packages/api/test/queue-gate-thread-level.test.js b/packages/api/test/queue-gate-thread-level.test.js index c747216cda..a3cd63ccce 100644 --- a/packages/api/test/queue-gate-thread-level.test.js +++ b/packages/api/test/queue-gate-thread-level.test.js @@ -32,6 +32,13 @@ function buildDeps(overrides = {}) { }, router: { resolveTargetsAndIntent: mock.fn(async () => ({ + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, targetCats: ['opus'], intent: { intent: 'execute' }, })), diff --git a/packages/api/test/queue-processor.test.js b/packages/api/test/queue-processor.test.js index e3bcd6148a..7cab297fe7 100644 --- a/packages/api/test/queue-processor.test.js +++ b/packages/api/test/queue-processor.test.js @@ -3150,4 +3150,24 @@ describe('QueueProcessor', () => { }); } }); + + it('F257 LI-001: queued execution forwards completionRequirement to routeExecution', async () => { + let capturedRequirement; + deps.router.routeExecution = mock.fn( + async function* (_userId, _content, _threadId, _messageId, _targetCats, _intent, options) { + capturedRequirement = options?.completionRequirement; + yield { type: 'done', catId: 'opus', isFinal: true, timestamp: Date.now() }; + }, + ); + + enqueueEntry(deps.queue, { + source: 'connector', + completionRequirement: 'action-or-routing-exit', + }); + const result = await processor.processNext('t1', 'u1'); + assert.equal(result.started, true); + await waitFor(() => capturedRequirement !== undefined); + + assert.equal(capturedRequirement, 'action-or-routing-exit'); + }); }); diff --git a/packages/api/test/read-latest-endpoint.test.js b/packages/api/test/read-latest-endpoint.test.js index 1096b5b32e..f44aa7c2ab 100644 --- a/packages/api/test/read-latest-endpoint.test.js +++ b/packages/api/test/read-latest-endpoint.test.js @@ -84,6 +84,7 @@ describe('POST /api/threads/:id/read/latest', () => { const thread = threadStore.create('alice', 'Thread with messages'); messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'alice', catId: 'opus', content: 'first', @@ -92,6 +93,7 @@ describe('POST /api/threads/:id/read/latest', () => { threadId: thread.id, }); const msg2 = messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'alice', catId: 'opus', content: 'second (latest)', @@ -114,6 +116,7 @@ describe('POST /api/threads/:id/read/latest', () => { it('is idempotent — second call returns advanced=false', async () => { const thread = threadStore.create('alice', 'Thread'); messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'alice', catId: 'opus', content: 'hello', diff --git a/packages/api/test/redis-message-delivery-atomicity.test.js b/packages/api/test/redis-message-delivery-atomicity.test.js index 0bc0234b64..e6ef5ac4bb 100644 --- a/packages/api/test/redis-message-delivery-atomicity.test.js +++ b/packages/api/test/redis-message-delivery-atomicity.test.js @@ -22,6 +22,7 @@ import { } from './helpers/redis-test-helpers.js'; const REDIS_URL = process.env.REDIS_URL; +const USER_PROVENANCE = { author: 'user', routed: false, observation: 'original' }; /** Per-file unique keyPrefix isolates this suite from concurrent redis-message-store / f232 tests. */ const TEST_KEY_PREFIX = 'cat-cafe-dlv-atomicity:'; @@ -71,6 +72,7 @@ describe('delivery-order transition atomicity (PR #1193)', { skip: redisIsolatio // ── Helper: create a queued message for testing ── const createQueued = (userId, threadId, ts) => store.append({ + provenance: USER_PROVENANCE, userId, catId: null, content: `queued-msg-${ts}`, @@ -295,6 +297,7 @@ describe('delivery-order transition atomicity (PR #1193)', { skip: redisIsolatio const threadId = 'thread-dlv-imm-9'; // Create a message WITHOUT deliveryStatus (= immediate/legacy) const msg = await store.append({ + provenance: USER_PROVENANCE, userId: 'userA', catId: null, content: 'immediate msg', @@ -387,6 +390,7 @@ describe('delivery-order transition atomicity (PR #1193)', { skip: redisIsolatio // Create a store WITH ttlSeconds to exercise the EXPIRE branch const ttlStore = new RedisMessageStore(redis, { ttlSeconds: 60 }); const msg = await ttlStore.append({ + provenance: USER_PROVENANCE, userId: 'userA', catId: null, content: 'ttl-test-msg', @@ -426,6 +430,7 @@ describe('in-memory MessageStore markCanceled guard (PR #1193)', () => { const memStore = new MessageStore(); const base = Date.now(); const msg = await memStore.append({ + provenance: USER_PROVENANCE, userId: 'u1', catId: null, content: 'test', @@ -446,6 +451,7 @@ describe('in-memory MessageStore markCanceled guard (PR #1193)', () => { it('markCanceled on immediate/no-status message is no-op', async () => { const memStore = new MessageStore(); const msg = await memStore.append({ + provenance: USER_PROVENANCE, userId: 'u1', catId: null, content: 'immediate', @@ -462,6 +468,7 @@ describe('in-memory MessageStore markCanceled guard (PR #1193)', () => { it('markCanceled on already-canceled message returns null (CAS idempotency parity)', async () => { const memStore = new MessageStore(); const msg = await memStore.append({ + provenance: USER_PROVENANCE, userId: 'u1', catId: null, content: 'test', diff --git a/packages/api/test/redis-message-store.test.js b/packages/api/test/redis-message-store.test.js index b4354e69c4..a4e502d117 100644 --- a/packages/api/test/redis-message-store.test.js +++ b/packages/api/test/redis-message-store.test.js @@ -12,13 +12,13 @@ import { } from './helpers/redis-test-helpers.js'; const REDIS_URL = process.env.REDIS_URL; +const USER_PROVENANCE = { author: 'user', routed: false, observation: 'original' }; describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () => { let RedisMessageStore; let generateSortableId; let collectAllThreadMessages; let createRedisClient; - let MessageKeys; let redis; let store; let connected = false; @@ -34,7 +34,6 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () )); const redisModule = await import('@cat-cafe/shared/utils'); createRedisClient = redisModule.createRedisClient; - ({ MessageKeys } = await import('../dist/domains/cats/services/stores/redis-keys/message-keys.js')); redis = createRedisClient({ url: REDIS_URL }); // Connectivity check: skip all tests if Redis is unreachable @@ -51,18 +50,19 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () after(async () => { if (redis && connected) { - await cleanupPrefixedRedisKeys(redis, ['msg:*']); + await cleanupPrefixedRedisKeys(redis, ['msg:*', 'routing-fact:*']); await redis.quit(); } }); beforeEach(async (t) => { if (!connected) return t.skip('Redis not connected'); - await cleanupPrefixedRedisKeys(redis, ['msg:*']); + await cleanupPrefixedRedisKeys(redis, ['msg:*', 'routing-fact:*']); }); it('append() stores message and returns with id', async () => { const msg = await store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user1', catId: null, content: 'hello', @@ -93,6 +93,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () for (const timestamp of invalidTimestamps) { await assert.rejects( admissionStore.append({ + provenance: USER_PROVENANCE, userId: 'user1', catId: null, content: 'must not persist', @@ -113,6 +114,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () const roundTripStore = new RedisMessageStore(redis, { ttlSeconds: 0 }); for (const timestamp of [0, 1, 8_640_000_000_000_000]) { const stored = await roundTripStore.append({ + provenance: USER_PROVENANCE, userId: 'user1', catId: null, content: 'valid Date input', @@ -140,6 +142,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () const threadId = 'thread-append-delivery-owner'; const timestamp = 100; const base = { + provenance: USER_PROVENANCE, userId, catId: null, content: 'delivery ownership probe', @@ -183,7 +186,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () const admissionStore = new RedisMessageStore(redis, { ttlSeconds: 0 }); const userId = 'user-cancel-owner'; const threadId = 'thread-cancel-owner'; - const base = { userId, catId: null, mentions: [], threadId }; + const base = { provenance: USER_PROVENANCE, userId, catId: null, mentions: [], threadId }; const queued = await admissionStore.append({ ...base, content: 'queued', @@ -246,6 +249,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () const userId = `user-delivery-admission-${index}`; const threadId = `thread-delivery-admission-${index}`; const queued = await admissionStore.append({ + provenance: USER_PROVENANCE, userId, catId: null, content: `queued ${index}`, @@ -317,6 +321,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () const admissionStore = new RedisMessageStore(redis, { ttlSeconds: 0 }); const threadId = 'thread-delivery-admission-pagination'; const first = await admissionStore.append({ + provenance: USER_PROVENANCE, userId: 'user-delivery-admission-pagination', catId: null, content: 'first', @@ -326,6 +331,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () deliveryStatus: 'queued', }); const second = await admissionStore.append({ + provenance: USER_PROVENANCE, userId: 'user-delivery-admission-pagination', catId: null, content: 'second', @@ -358,6 +364,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () const sourceUserId = `user-delivery-reassign-source-${suffix}`; const targetUserId = `user-delivery-reassign-target-${suffix}`; const queued = await admissionStore.append({ + provenance: USER_PROVENANCE, userId: sourceUserId, catId: null, content: suffix, @@ -388,6 +395,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () for (const timestamp of [2, 8_640_000_000_000_000]) { later.push( await roundTripStore.append({ + provenance: USER_PROVENANCE, userId: 'user1', catId: null, content: `timestamp ${timestamp}`, @@ -426,9 +434,30 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () it('getRecent() returns messages in chronological order', async () => { const now = Date.now(); - await store.append({ userId: 'u', catId: null, content: 'first', mentions: [], timestamp: now }); - await store.append({ userId: 'u', catId: 'opus', content: 'second', mentions: [], timestamp: now + 1 }); - await store.append({ userId: 'u', catId: null, content: 'third', mentions: [], timestamp: now + 2 }); + await store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'u', + catId: null, + content: 'first', + mentions: [], + timestamp: now, + }); + await store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, + userId: 'u', + catId: 'opus', + content: 'second', + mentions: [], + timestamp: now + 1, + }); + await store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'u', + catId: null, + content: 'third', + mentions: [], + timestamp: now + 2, + }); const recent = await store.getRecent(10); assert.equal(recent.length, 3); @@ -438,8 +467,22 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () it('getRecent() filters by userId', async () => { const now = Date.now(); - await store.append({ userId: 'alice', catId: null, content: 'alice msg', mentions: [], timestamp: now }); - await store.append({ userId: 'bob', catId: null, content: 'bob msg', mentions: [], timestamp: now + 1 }); + await store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'alice', + catId: null, + content: 'alice msg', + mentions: [], + timestamp: now, + }); + await store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'bob', + catId: null, + content: 'bob msg', + mentions: [], + timestamp: now + 1, + }); const aliceOnly = await store.getRecent(10, 'alice'); assert.equal(aliceOnly.length, 1); @@ -492,9 +535,24 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () it('getMentionsFor() returns messages mentioning a specific cat', async () => { const now = Date.now(); - await store.append({ userId: 'u', catId: null, content: 'hi opus', mentions: ['opus'], timestamp: now }); - await store.append({ userId: 'u', catId: null, content: 'hi codex', mentions: ['codex'], timestamp: now + 1 }); await store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'u', + catId: null, + content: 'hi opus', + mentions: ['opus'], + timestamp: now, + }); + await store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'u', + catId: null, + content: 'hi codex', + mentions: ['codex'], + timestamp: now + 1, + }); + await store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u', catId: null, content: 'hi both', @@ -511,6 +569,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () it('getMentionsFor() filters by threadId (#75)', async () => { const now = Date.now(); await store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u', catId: null, content: '@opus in tA', @@ -519,6 +578,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () threadId: 'thread-A', }); await store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u', catId: null, content: '@opus in tB', @@ -527,6 +587,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () threadId: 'thread-B', }); await store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u', catId: null, content: '@opus in tA again', @@ -547,9 +608,30 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () it('getBefore() returns messages before timestamp', async () => { const base = Date.now(); - await store.append({ userId: 'u', catId: null, content: 'old', mentions: [], timestamp: base }); - await store.append({ userId: 'u', catId: null, content: 'mid', mentions: [], timestamp: base + 100 }); - await store.append({ userId: 'u', catId: null, content: 'new', mentions: [], timestamp: base + 200 }); + await store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'u', + catId: null, + content: 'old', + mentions: [], + timestamp: base, + }); + await store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'u', + catId: null, + content: 'mid', + mentions: [], + timestamp: base + 100, + }); + await store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'u', + catId: null, + content: 'new', + mentions: [], + timestamp: base + 200, + }); const before = await store.getBefore(base + 200, 10); assert.equal(before.length, 2); @@ -560,7 +642,14 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () it('getBefore() respects limit', async () => { const base = Date.now(); for (let i = 0; i < 5; i++) { - await store.append({ userId: 'u', catId: null, content: `msg${i}`, mentions: [], timestamp: base + i }); + await store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'u', + catId: null, + content: `msg${i}`, + mentions: [], + timestamp: base + i, + }); } const before = await store.getBefore(base + 5, 2); @@ -594,6 +683,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () const earlier = fixture.earlierTimestamp !== null ? await store.append({ + provenance: USER_PROVENANCE, userId, catId: null, content: `earlier than ${fixture.label}`, @@ -643,6 +733,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () for (const [index, fixture] of cases.entries()) { const threadId = `thread-legacy-${fixture.label}-collector`; const earlier = await store.append({ + provenance: USER_PROVENANCE, userId: 'u', catId: null, content: `earlier than ${fixture.label}`, @@ -685,6 +776,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () it('augmentStreamMetadata() persists stream-only metadata onto callback messages', async () => { const msg = await store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'u', catId: 'opus', content: 'callback canonical', @@ -723,6 +815,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () it('hardDelete clears toolEvents from returned object and Redis', async () => { const msg = await store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'u', catId: 'opus', content: 'tool msg', @@ -750,6 +843,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () it('hardDelete clears thinking from returned object and Redis (F045 security)', async () => { const msg = await store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'u', catId: 'opus', content: 'response with thinking', @@ -772,8 +866,271 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () assert.equal(refetched.thinking, undefined, 'Redis should not return thinking after hardDelete'); }); + it('R8: hardDelete removes token-bearing F257 fields from returned object and Redis', async () => { + const routingFact = { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [ + { tokenOrdinal: 0, outcome: 'resolved', token: '@opus', span: { start: 0, end: 5 }, targetCatId: 'opus' }, + ], + truncated: false, + metricEligible: true, + }; + const msg = await store.append({ + provenance: { author: 'user', routed: true, observation: 'original' }, + routingFact, + userId: 'u', + catId: null, + content: '@opus private request', + mentions: ['opus'], + timestamp: Date.now(), + }); + + const deleted = await store.hardDelete(msg.id, 'admin'); + assert.equal(deleted.routingFact, undefined); + assert.equal(deleted.provenance, undefined); + assert.equal(await redis.hget(`msg:${msg.id}`, 'routingFact'), null); + assert.equal(await redis.hget(`msg:${msg.id}`, 'provenance'), null); + }); + + it('R9: deleteByThread fences empty threads and converges orphan index members', async () => { + const calls = []; + const deletionStore = new RedisMessageStore(redis, { + ttlSeconds: 60, + onBeforeDeleteByThread: (threadId) => calls.push(threadId), + }); + + assert.equal(await deletionStore.deleteByThread('thread-empty-delete'), 0); + assert.deepEqual(calls, ['thread-empty-delete'], 'empty physical delete still executes the terminal scrub hook'); + + const threadId = 'thread-orphan-delete'; + const orphanId = 'orphan-message-id'; + const score = Date.now(); + await redis.zadd(`msg:thread:${threadId}`, String(score), orphanId); + await redis.zadd('msg:timeline', String(score), orphanId); + await redis.zadd('msg:user:orphan-owner', String(score), orphanId); + await redis.zadd('msg:mentions:opus', String(score), orphanId); + await redis.zadd('routing-fact:idx:orphan-owner', String(score), orphanId); + await redis.zadd('routing-fact:proj-errors:orphan-owner', String(score), orphanId); + + assert.equal(await deletionStore.deleteByThread(threadId), 1); + assert.equal(await redis.zscore(`msg:thread:${threadId}`, orphanId), null); + assert.equal(await redis.zscore('msg:timeline', orphanId), null); + assert.equal(await redis.zscore('msg:user:orphan-owner', orphanId), null); + assert.equal(await redis.zscore('msg:mentions:opus', orphanId), null); + assert.equal(await redis.zscore('routing-fact:idx:orphan-owner', orphanId), null); + assert.equal(await redis.zscore('routing-fact:proj-errors:orphan-owner', orphanId), null); + assert.deepEqual(calls, ['thread-empty-delete', threadId]); + + const hiddenThreadId = 'thread-hidden-authority-delete'; + const hidden = await deletionStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'hidden-owner', + catId: null, + content: 'authority hash not present in its thread index', + mentions: ['codex'], + timestamp: Date.now(), + threadId: hiddenThreadId, + idempotencyKey: 'hidden-authority-idem', + }); + await redis.zrem(`msg:thread:${hiddenThreadId}`, hidden.id); + assert.equal(await deletionStore.deleteByThread(hiddenThreadId), 1, 'authority hash scan closes sparse index gaps'); + assert.equal(await redis.exists(`msg:${hidden.id}`), 0); + assert.equal(await redis.zscore('msg:user:hidden-owner', hidden.id), null); + assert.equal(await redis.zscore('msg:mentions:codex', hidden.id), null); + assert.equal(await redis.get(`msg:idem:hidden-owner:${hiddenThreadId}:hidden-authority-idem`), null); + assert.deepEqual(calls, ['thread-empty-delete', threadId, hiddenThreadId]); + + const retryThreadId = 'thread-physical-cleanup-retry'; + const retryMessage = await deletionStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'physical-retry-owner', + catId: null, + content: 'retain discovery anchor until sibling cleanup succeeds', + mentions: [], + timestamp: Date.now(), + threadId: retryThreadId, + }); + await redis.zrem(`msg:thread:${retryThreadId}`, retryMessage.id); + assert.equal( + await redis.zscore(`msg:thread:${retryThreadId}`, retryMessage.id), + null, + 'authority scan, not a healthy thread index, must discover the message', + ); + const corruptIndexKey = 'routing-fact:idx:wrong-type-owner'; + await redis.set(corruptIndexKey, 'wrong-type'); + await assert.rejects(() => deletionStore.deleteByThread(retryThreadId), /WRONGTYPE/); + assert.equal(await redis.exists(`msg:${retryMessage.id}`), 0, 'authority transition stays privacy-first'); + assert.ok( + await redis.zscore(`msg:thread:${retryThreadId}`, retryMessage.id), + 'thread member remains as the retry discovery anchor', + ); + await redis.del(corruptIndexKey); + assert.equal(await deletionStore.deleteByThread(retryThreadId), 1); + assert.equal(await redis.zscore(`msg:thread:${retryThreadId}`, retryMessage.id), null); + }); + + it('R9: restore cannot clear deletion markers after concurrent hard delete linearizes', async () => { + const msg = await store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'restore-race-owner', + catId: null, + content: 'restore race', + mentions: [], + timestamp: Date.now(), + threadId: 'restore-race-thread', + }); + await store.softDelete(msg.id, 'restore-race-owner'); + + const originalGetById = store.getById.bind(store); + let firstRead = true; + let announceRestoreRead; + let releaseRestoreRead; + const restoreRead = new Promise((resolve) => { + announceRestoreRead = resolve; + }); + const restoreRelease = new Promise((resolve) => { + releaseRestoreRead = resolve; + }); + store.getById = async (id) => { + const value = await originalGetById(id); + if (firstRead) { + firstRead = false; + announceRestoreRead(); + await restoreRelease; + } + return value; + }; + + try { + const restorePromise = store.restore(msg.id); + await restoreRead; + const hardDeleted = await store.hardDelete(msg.id, 'admin'); + assert.equal(hardDeleted._tombstone, true); + releaseRestoreRead(); + assert.equal(await restorePromise, null, 'restore loses once hard delete has linearized'); + } finally { + store.getById = originalGetById; + } + + const raw = await redis.hmget(`msg:${msg.id}`, '_tombstone', 'deletedAt', 'deletedBy'); + assert.equal(raw[0], '1'); + assert.ok(raw[1]); + assert.equal(raw[2], 'admin'); + }); + + it('R10: hard tombstones reject every Redis authority mutator without changing bytes or indexes', async () => { + const msg = await store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, + userId: 'terminal-owner', + catId: 'opus', + content: 'sensitive payload', + mentions: [], + timestamp: Date.now(), + threadId: 'thread-r10-terminal', + visibility: 'whisper', + deliveryStatus: 'queued', + extra: { stream: { invocationId: 'old-invocation' } }, + thinking: 'sensitive thinking', + }); + const deleted = await store.hardDelete(msg.id, 'admin'); + assert.ok(deleted); + const rawBefore = await redis.hgetall(`msg:${msg.id}`); + + const results = { + softDelete: await store.softDelete(msg.id, 'other-admin'), + restore: await store.restore(msg.id), + hardDelete: await store.hardDelete(msg.id, 'other-admin'), + updateExtra: await store.updateExtra(msg.id, { tracing: { traceId: 'revived', spanId: 'revived' } }), + augment: await store.augmentStreamMetadata(msg.id, { + thinking: 'revived thinking', + toolEvents: [{ id: 'revived-tool', type: 'tool_use', label: 'revived', timestamp: Date.now() }], + }), + delivered: await store.markDelivered(msg.id, Date.now() + 100), + canceled: await store.markCanceled(msg.id), + reassigned: await store.reassignUserId(msg.id, 'revived-owner'), + revealed: await store.revealWhispers(msg.threadId, msg.userId), + }; + + assert.deepEqual(results, { + softDelete: null, + restore: null, + hardDelete: null, + updateExtra: null, + augment: null, + delivered: null, + canceled: null, + reassigned: null, + revealed: 0, + }); + assert.deepEqual(await redis.hgetall(`msg:${msg.id}`), rawBefore, 'terminal tombstone bytes remain unchanged'); + assert.equal(await redis.zscore('msg:user:revived-owner', msg.id), null); + + await redis.zadd('routing-fact:idx:historic-owner', msg.timestamp, msg.id); + await redis.zadd('routing-fact:proj-errors:historic-owner', Date.now(), msg.id); + assert.equal(await store.hardDelete(msg.id, 'cleanup-retry'), null, 'repeated hard delete remains a no-op'); + assert.equal( + await redis.zscore('routing-fact:idx:historic-owner', msg.id), + null, + 'cleanup retry removes a projection stranded under a historic owner', + ); + assert.equal( + await redis.zscore('routing-fact:proj-errors:historic-owner', msg.id), + null, + 'cleanup retry removes a historic-owner projection error', + ); + }); + + it('R10: a stale payload writer cannot recreate data after hard delete linearizes', async () => { + const msg = await store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, + userId: 'stale-payload-owner', + catId: 'opus', + content: 'sensitive payload', + mentions: [], + timestamp: Date.now(), + threadId: 'thread-r10-stale-payload', + extra: { stream: { invocationId: 'old-invocation' } }, + }); + + const originalGetById = store.getById.bind(store); + let firstRead = true; + let announcePayloadRead; + let releasePayloadRead; + const payloadRead = new Promise((resolve) => { + announcePayloadRead = resolve; + }); + const payloadRelease = new Promise((resolve) => { + releasePayloadRead = resolve; + }); + store.getById = async (id) => { + const value = await originalGetById(id); + if (firstRead) { + firstRead = false; + announcePayloadRead(); + await payloadRelease; + } + return value; + }; + + try { + const staleWrite = store.updateExtra(msg.id, { tracing: { traceId: 'revived', spanId: 'revived' } }); + await payloadRead; + const hardDeleted = await store.hardDelete(msg.id, 'admin'); + assert.equal(hardDeleted._tombstone, true); + releasePayloadRead(); + assert.equal(await staleWrite, null, 'writer loses once hard delete has linearized'); + } finally { + store.getById = originalGetById; + } + + const raw = await redis.hmget(`msg:${msg.id}`, '_tombstone', 'extra', 'thinking', 'toolEvents'); + assert.deepEqual(raw, ['1', '', '', '']); + }); + it('message TTL is set', async () => { const msg = await store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u', catId: null, content: 'ttl test', @@ -787,6 +1144,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () it('append() with same idempotencyKey returns existing message', async () => { const first = await store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u1', catId: null, content: 'kickoff', @@ -797,6 +1155,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () }); const second = await store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u1', catId: null, content: 'kickoff retried', @@ -816,6 +1175,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () it('F057-C2: mentionsUser round-trips through append/getById', async () => { const msg = await store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'u', catId: 'opus', content: '@co-creator 看看这个', @@ -833,6 +1193,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () it('F057-C2: mentionsUser round-trips through hydrateMessages (getByThread)', async () => { const now = Date.now(); await store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'u', catId: 'opus', content: '@user please check', @@ -842,6 +1203,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () mentionsUser: true, }); await store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u', catId: null, content: 'normal message', @@ -862,6 +1224,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () // msgA sent first (base), msgB sent second (base+100) — both queued const msgA = await store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u', catId: null, content: 'msgA-sent-first', @@ -871,6 +1234,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () deliveryStatus: 'queued', }); const msgB = await store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u', catId: null, content: 'msgB-sent-second', @@ -904,6 +1268,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () // agentReply at base (simulates invocation start time) — already delivered (no deliveryStatus) const agentReply = await store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'u', catId: 'opus', content: 'agent-reply', @@ -915,6 +1280,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () // Without zadd re-scoring, original timestamp (base-10) < cursor (base), so it would NOT // appear; only deliveredAt re-scoring (base+500 > base) makes it visible after cursor. const queuedMsg = await store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u', catId: null, content: 'queued-user-msg', @@ -936,6 +1302,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () it('F148: origin=briefing survives append → getById round-trip', async () => { const msg = await store.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, userId: 'system', catId: null, content: 'briefing summary', @@ -955,6 +1322,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () it('F148: origin=briefing survives hydrateMessages (getByThread)', async () => { const now = Date.now(); await store.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, userId: 'system', catId: null, content: 'briefing card', @@ -964,6 +1332,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () origin: 'briefing', }); await store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u', catId: null, content: 'normal', @@ -984,6 +1353,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () const now = Date.now(); // Create messages with different delivery statuses const m1 = await store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u1', catId: null, content: 'queued msg 1', @@ -993,6 +1363,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () deliveryStatus: 'queued', }); const m2 = await store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u1', catId: null, content: 'delivered msg', @@ -1001,6 +1372,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () threadId: 'thread-scan-1', }); const m3 = await store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u1', catId: null, content: 'queued msg 2', @@ -1023,6 +1395,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () it('scanByDeliveryStatus returns empty array when no matches', async () => { const now = Date.now(); await store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u1', catId: null, content: 'normal msg', @@ -1040,6 +1413,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () const created = []; for (let i = 0; i < 5; i++) { const msg = await store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u1', catId: null, content: `queued ${i}`, @@ -1063,6 +1437,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () it('scanByDeliveryStatus finds canceled messages', async () => { const now = Date.now(); const m1 = await store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'u1', catId: null, content: 'will be canceled', @@ -1079,216 +1454,206 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () const queuedIds = await store.scanByDeliveryStatus('queued'); assert.ok(!queuedIds.includes(m1.id), 'should not find canceled message in queued scan'); }); +}); - it('concurrent idempotent append creates exactly one thread member', async () => { - const threadId = 'thread-concurrent-idem'; - const key = MessageKeys.thread(threadId); - const timestamp = Date.now(); +describe('F257 V1: routingFact embedded authority (Redis)', { skip: redisIsolationSkipReason(REDIS_URL) }, () => { + let RedisMessageStore; + let redis; + let store; + let connected = false; - const [a, b] = await Promise.all([ - store.append({ - userId: 'u1', - catId: null, - content: 'concurrent', - mentions: [], - timestamp, - threadId, - idempotencyKey: 'concurrent-idem', - }), - store.append({ - userId: 'u1', - catId: null, - content: 'concurrent', - mentions: [], - timestamp, - threadId, - idempotencyKey: 'concurrent-idem', - }), - ]); + const SAMPLE_BATCH = { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [ + { tokenOrdinal: 0, outcome: 'resolved', token: '@codex', span: { start: 3, end: 9 }, targetCatId: 'codex' }, + { tokenOrdinal: 1, outcome: 'unknown_token', token: '@zzz', span: { start: 12, end: 16 } }, + ], + truncated: false, + metricEligible: true, + }; - assert.equal(a.id, b.id, 'both concurrent callers must observe the same message id'); - const members = await redis.zrange(key, 0, -1); - assert.deepEqual(members, [a.id], 'thread zset must contain exactly the created message'); + before(async () => { + assertRedisIsolationOrThrow(REDIS_URL, 'RedisMessageStore routingFact'); + const storeModule = await import('../dist/domains/cats/services/stores/redis/RedisMessageStore.js'); + RedisMessageStore = storeModule.RedisMessageStore; + const redisModule = await import('@cat-cafe/shared/utils'); + redis = redisModule.createRedisClient({ url: REDIS_URL }); + try { + await redis.ping(); + connected = true; + } catch { + await redis.quit().catch(() => {}); + return; + } + store = new RedisMessageStore(redis, { ttlSeconds: 60 }); }); - it('idempotent replay does not refire onAppend', async () => { - let calls = 0; - const timestamp = Date.now(); - const watchedStore = new RedisMessageStore(redis, { - ttlSeconds: 60, - onAppend: () => { - calls++; - }, - }); + after(async () => { + if (redis && connected) { + await cleanupPrefixedRedisKeys(redis, ['msg:*']); + await redis.quit(); + } + }); - const first = await watchedStore.append({ - userId: 'u1', - catId: null, - content: 'idem', - mentions: [], - timestamp, - threadId: 'thread-redis-onappend', - idempotencyKey: 'redis-onappend', - }); - assert.equal(calls, 1); + beforeEach(async (t) => { + if (!connected) return t.skip('Redis not connected'); + await cleanupPrefixedRedisKeys(redis, ['msg:*']); + }); - const replay = await watchedStore.append({ - userId: 'u1', + it('append() persists routingFact in the message hash and getById round-trips it', async () => { + const stored = await store.append({ + userId: 'user-1', catId: null, - content: 'idem retry', - mentions: [], - timestamp: timestamp + 1, - threadId: 'thread-redis-onappend', - idempotencyKey: 'redis-onappend', + content: '找 @codex 和 @zzz', + mentions: ['codex'], + timestamp: Date.now(), + threadId: 'th-f257', + routingFact: SAMPLE_BATCH, + provenance: { author: 'user', routed: true, observation: 'original' }, }); - assert.equal(replay.id, first.id); - assert.equal(calls, 1, 'idempotent replay must not refire onAppend'); + assert.deepEqual(stored.routingFact, SAMPLE_BATCH, 'append return value carries the fact'); + const fetched = await store.getById(stored.id); + assert.deepEqual(fetched?.routingFact, SAMPLE_BATCH, 'getById round-trips the fact'); }); - it('idempotent replay preserves explicitly empty optional arrays', async () => { - const input = { - userId: 'u1', + it('hydrate path (getByThread) round-trips routingFact', async () => { + await store.append({ + userId: 'user-1', catId: null, - content: 'empty arrays', - contentBlocks: [], - toolEvents: [], - mentions: [], + content: '找 @codex', + mentions: ['codex'], timestamp: Date.now(), - threadId: 'thread-empty-arrays', - whisperTo: [], - idempotencyKey: 'empty-arrays', - }; - - const first = await store.append(input); - const replay = await store.append(input); - const hydrated = await store.getById(first.id); - - for (const message of [first, replay, hydrated]) { - assert.deepEqual(message?.contentBlocks, []); - assert.deepEqual(message?.toolEvents, []); - assert.deepEqual(message?.whisperTo, []); - } + threadId: 'th-f257-hydrate', + routingFact: SAMPLE_BATCH, + provenance: { author: 'user', routed: true, observation: 'original' }, + }); + const msgs = await store.getByThread('th-f257-hydrate', 10); + assert.equal(msgs.length, 1); + assert.deepEqual(msgs[0].routingFact, SAMPLE_BATCH); }); - it('atomically reclaims an idempotency key whose message hash is missing', async () => { - const userId = 'u1'; - const threadId = 'thread-stale-idem'; - const idempotencyKey = 'stale-idem'; - const redisKey = MessageKeys.idempotency(userId, threadId, idempotencyKey); - const missingId = generateSortableId(Date.now() - 1); - await redis.set(redisKey, missingId); - - const created = await store.append({ - userId, + it('append() persists an empty-attempts batch and tolerates a malformed stored field', async () => { + const emptyBatch = { ...SAMPLE_BATCH, attempts: [] }; + const stored = await store.append({ + userId: 'user-1', catId: null, - content: 'replacement', + content: 'no tokens', mentions: [], timestamp: Date.now(), - threadId, - idempotencyKey, + threadId: 'th-f257-empty', + routingFact: emptyBatch, + provenance: { author: 'user', routed: true, observation: 'original' }, }); - const replay = await store.append({ - userId, + const fetched = await store.getById(stored.id); + // sol R1 P1-1: zero-token batches persist — the fact field is the + // producer-run marker the coverage cohort audits. + assert.deepEqual(fetched?.routingFact, emptyBatch, 'empty batch persists as producer-run marker'); + + // Malformed field must not break message reads (safe-parse contract) + await redis.hset(`msg:${stored.id}`, { routingFact: '{not json' }); + const refetched = await store.getById(stored.id); + assert.ok(refetched, 'message still readable'); + assert.equal(refetched.routingFact, undefined, 'malformed fact parses to undefined'); + }); + + it('append() provenance roundtrips all three axes', async () => { + const stored = await store.append({ + userId: 'user-lane', catId: null, - content: 'must replay replacement', - mentions: [], - timestamp: Date.now() + 1, - threadId, - idempotencyKey, + content: '@opus hi', + mentions: ['opus'], + timestamp: Date.now(), + threadId: 'th-f257-lane', + routingFact: SAMPLE_BATCH, + provenance: { author: 'user', routed: true, observation: 'original' }, }); + const fetched = await store.getById(stored.id); + assert.deepEqual(fetched?.provenance, { author: 'user', routed: true, observation: 'original' }); - assert.notEqual(created.id, missingId); - assert.equal(await redis.get(redisKey), created.id, 'stale mapping must be replaced by the new winner'); - assert.equal(replay.id, created.id, 'the replacement mapping must remain idempotent'); - assert.deepEqual(await redis.zrange(MessageKeys.thread(threadId), 0, -1), [created.id]); - }); + // sol R4 P1-1b: a declaration-less append no longer exists — the write + // boundary rejects it outright (uncompiled callers included) + await assert.rejects( + store.append({ + userId: 'user-lane', + catId: null, + content: 'card', + mentions: [], + timestamp: Date.now(), + threadId: 'th-f257-lane', + }), + /append requires provenance/, + ); - it('fails closed when an idempotency winner vanishes before hydration', async () => { - const userId = 'u1'; - const threadId = 'thread-vanished-winner'; - const idempotencyKey = 'vanished-winner'; - const winner = await store.append({ - userId, + // absent-field rows (written before the contract) still hydrate as + // "no trusted declaration" — out of every cohort + const surface = await store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: 'user-lane', catId: null, - content: 'winner', + content: 'card', mentions: [], timestamp: Date.now(), - threadId, - idempotencyKey, + threadId: 'th-f257-lane', }); + await redis.hdel(`msg:${surface.id}`, 'provenance'); + assert.equal( + (await store.getById(surface.id))?.provenance, + undefined, + 'absent field = legacy pre-contract row — no trusted declaration', + ); + }); - let listenerCalls = 0; - const watchedStore = new RedisMessageStore(redis, { - ttlSeconds: 60, - onAppend: () => { - listenerCalls++; - }, - }); - const redisKey = MessageKeys.idempotency(userId, threadId, idempotencyKey); - const originalGet = redis.get; - const originalEval = redis.eval; - let bypassFastPath = true; - let removeWinnerAfterLua = true; - - redis.get = async function (key, ...args) { - if (bypassFastPath && key === redisKey) { - bypassFastPath = false; - return null; - } - return originalGet.call(this, key, ...args); - }; - redis.eval = async function (...args) { - const result = await originalEval.apply(this, args); - if (removeWinnerAfterLua && result === winner.id) { - removeWinnerAfterLua = false; - await this.del(MessageKeys.detail(winner.id)); - } - return result; - }; + it('append() surfaces per-command MULTI errors and undoes partial writes (sol R2 P1-3)', async () => { + const userId = 'user-exec-err'; + // Break the user timeline key type so the pipeline's ZADD fails per-command + await redis.set(`msg:user:${userId}`, 'wrong-type'); - try { - await assert.rejects( - watchedStore.append({ + await assert.rejects( + () => + store.append({ userId, catId: null, - content: 'loser', - mentions: [], - timestamp: Date.now() + 1, - threadId, - idempotencyKey, + content: '@opus 看下', + mentions: ['opus'], + timestamp: Date.now(), + threadId: 'th-f257-execerr', + routingFact: SAMPLE_BATCH, + provenance: { author: 'user', routed: true, observation: 'original' }, + idempotencyKey: 'exec-err-1', }), - /Idempotency winner .* vanished before hydration/, - ); - } finally { - redis.get = originalGet; - redis.eval = originalEval; - } - - assert.equal(listenerCalls, 0, 'a non-persisted loser must not fire onAppend'); - assert.deepEqual(await redis.zrange(MessageKeys.thread(threadId), 0, -1), [winner.id]); - }); + /WRONGTYPE|wrong kind/i, + 'append must not report success over a failed index write', + ); - it('prunes stale members from an active TTL-backed thread index', async () => { - const threadId = 'thread-active-ttl-prune'; - const threadKey = MessageKeys.thread(threadId); - const staleId = generateSortableId(Date.now() - 120_000); - await redis.zadd(threadKey, Date.now() - 120_000, staleId); + // Partial-execution cleanup: no orphan hash, no ghost thread-timeline entry, + // and the idempotency claim is rolled back. + const threadIds = await redis.zrange('msg:thread:th-f257-execerr', 0, -1); + assert.deepEqual(threadIds, [], 'thread timeline must not keep a ghost entry'); + const globalIds = await redis.zrange('msg:timeline', 0, -1); + for (const id of globalIds) { + const hash = await redis.hgetall(`msg:${id}`); + assert.notEqual(hash.threadId, 'th-f257-execerr', 'no orphan hash for the failed append'); + } + assert.equal( + await redis.get('msg:idem:user-exec-err:th-f257-execerr:exec-err-1'), + null, + 'idempotency claim rolled back', + ); - const ttlStore = new RedisMessageStore(redis, { ttlSeconds: 60 }); - const current = await ttlStore.append({ - userId: 'u1', + // After the operator repairs the key, the same append succeeds cleanly. + await redis.del(`msg:user:${userId}`); + const ok = await store.append({ + userId, catId: null, - content: 'keeps thread active', - mentions: [], + content: '@opus 看下', + mentions: ['opus'], timestamp: Date.now(), - threadId, + threadId: 'th-f257-execerr', + routingFact: SAMPLE_BATCH, + provenance: { author: 'user', routed: true, observation: 'original' }, + idempotencyKey: 'exec-err-1', }); - - assert.deepEqual( - await redis.zrange(threadKey, 0, -1), - [current.id], - 'append must remove expired-score members even while refreshing the thread key TTL', - ); - assert.ok((await redis.ttl(threadKey)) > 0, 'thread index must remain active after member pruning'); + assert.ok(ok.id, 'append succeeds after repair with the same idempotency key'); }); }); diff --git a/packages/api/test/redis-read-state-store.test.js b/packages/api/test/redis-read-state-store.test.js index 6c1f47bb64..70f645c552 100644 --- a/packages/api/test/redis-read-state-store.test.js +++ b/packages/api/test/redis-read-state-store.test.js @@ -117,6 +117,7 @@ describe('RedisThreadReadStateStore', { skip: redisIsolationSkipReason(REDIS_URL const tid = uniqueId('t'); // Cat messages share same userId as tenant — catId distinguishes them const m1 = await messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user1', catId: 'opus', content: 'hello', @@ -125,6 +126,7 @@ describe('RedisThreadReadStateStore', { skip: redisIsolationSkipReason(REDIS_URL threadId: tid, }); await messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user1', catId: 'opus', content: 'world', @@ -133,6 +135,7 @@ describe('RedisThreadReadStateStore', { skip: redisIsolationSkipReason(REDIS_URL threadId: tid, }); await messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user1', catId: 'opus', content: 'test', @@ -154,6 +157,7 @@ describe('RedisThreadReadStateStore', { skip: redisIsolationSkipReason(REDIS_URL const tid = uniqueId('t'); // Cat message (catId='opus') — should be counted const m1 = await messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user1', catId: 'opus', content: 'cat reply', @@ -163,6 +167,7 @@ describe('RedisThreadReadStateStore', { skip: redisIsolationSkipReason(REDIS_URL }); // User's own message (catId=null) — should NOT be counted await messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user1', catId: null, content: 'my question', @@ -172,6 +177,7 @@ describe('RedisThreadReadStateStore', { skip: redisIsolationSkipReason(REDIS_URL }); // Cat reply (catId='opus') — should be counted await messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user1', catId: 'opus', content: 'cat reply 2', @@ -190,6 +196,7 @@ describe('RedisThreadReadStateStore', { skip: redisIsolationSkipReason(REDIS_URL it('getUnreadSummaries() excludes deleted messages from count', async () => { const tid = uniqueId('t'); const m1 = await messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user1', catId: 'opus', content: 'hello', @@ -198,6 +205,7 @@ describe('RedisThreadReadStateStore', { skip: redisIsolationSkipReason(REDIS_URL threadId: tid, }); const m2 = await messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user1', catId: 'opus', content: 'to delete', @@ -206,6 +214,7 @@ describe('RedisThreadReadStateStore', { skip: redisIsolationSkipReason(REDIS_URL threadId: tid, }); await messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user1', catId: 'opus', content: 'keep', @@ -225,6 +234,7 @@ describe('RedisThreadReadStateStore', { skip: redisIsolationSkipReason(REDIS_URL it('getUnreadSummaries() detects mentionsUser', async () => { const tid = uniqueId('t'); const m1 = await messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user1', catId: 'opus', content: 'hello', @@ -233,6 +243,7 @@ describe('RedisThreadReadStateStore', { skip: redisIsolationSkipReason(REDIS_URL threadId: tid, }); await messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user1', catId: 'opus', content: '@co-creator look', @@ -251,6 +262,7 @@ describe('RedisThreadReadStateStore', { skip: redisIsolationSkipReason(REDIS_URL it('getUnreadSummaries() returns 0 for fully read thread', async () => { const tid = uniqueId('t'); const m1 = await messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user1', catId: 'opus', content: 'hello', @@ -267,6 +279,7 @@ describe('RedisThreadReadStateStore', { skip: redisIsolationSkipReason(REDIS_URL it('getUnreadSummaries() treats no cursor as fully read (cold-start guard)', async () => { const tid = uniqueId('t'); await messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user1', catId: 'opus', content: 'hello', @@ -275,6 +288,7 @@ describe('RedisThreadReadStateStore', { skip: redisIsolationSkipReason(REDIS_URL threadId: tid, }); await messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user1', catId: 'opus', content: 'world', @@ -295,6 +309,7 @@ describe('RedisThreadReadStateStore', { skip: redisIsolationSkipReason(REDIS_URL const tA = uniqueId('t'); const tB = uniqueId('t'); const mA1 = await messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user1', catId: 'opus', content: 'a1', @@ -303,6 +318,7 @@ describe('RedisThreadReadStateStore', { skip: redisIsolationSkipReason(REDIS_URL threadId: tA, }); await messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user1', catId: 'opus', content: 'a2', @@ -311,6 +327,7 @@ describe('RedisThreadReadStateStore', { skip: redisIsolationSkipReason(REDIS_URL threadId: tA, }); await messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user1', catId: 'opus', content: 'b', diff --git a/packages/api/test/redis-routing-fact-projection.test.js b/packages/api/test/redis-routing-fact-projection.test.js new file mode 100644 index 0000000000..7c68fcb502 --- /dev/null +++ b/packages/api/test/redis-routing-fact-projection.test.js @@ -0,0 +1,868 @@ +/** + * F257 V1 — RoutingDecisionFact projection tests (§4.5.1 contract). + * + * Semantics single source of truth: F257 redesign doc §4.5.1 (projection + * coverage contract) + T-A §3.4 (metric columns via routing-attempt.ts). + * 有 Redis → 测全量;无 Redis → skip(与 redis-message-store.test.js 同模式)。 + */ + +import assert from 'node:assert/strict'; +import { after, before, beforeEach, describe, it } from 'node:test'; +import { + assertRedisIsolationOrThrow, + cleanupClientKeyspace, + redisIsolationSkipReason, +} from './helpers/redis-test-helpers.js'; + +const REDIS_URL = process.env.REDIS_URL; +const OWNER = 'owner-f257'; +// Per-file keyPrefix: hard keyspace isolation from concurrently running test +// files (cleanupClientKeyspace precedent — cohort reads join timeline↔hash, +// so another file's `msg:*` wildcard cleanup mid-test corrupts the audit). +const TEST_KEY_PREFIX = 'cat-cafe:f257proj:'; + +function a2aBatch(overrides = {}) { + return { + parserMode: 'a2a', + spanBasis: 'a2a_normalized', + attempts: [ + { tokenOrdinal: 0, outcome: 'resolved', token: '@codex', span: { start: 0, end: 6 }, targetCatId: 'codex' }, + { tokenOrdinal: 1, outcome: 'unknown_token', token: '@zzz', span: { start: 7, end: 11 } }, + { tokenOrdinal: 2, outcome: 'duplicate', token: '@缅因猫', span: { start: 12, end: 16 }, targetCatId: 'codex' }, + ], + truncated: false, + metricEligible: true, + ...overrides, + }; +} + +function userBatch(overrides = {}) { + return { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [ + { tokenOrdinal: 0, outcome: 'resolved', token: '@opus', span: { start: 0, end: 5 }, targetCatId: 'opus' }, + ], + truncated: false, + metricEligible: true, + ...overrides, + }; +} + +describe('F257 V1: RedisRoutingFactProjection', { skip: redisIsolationSkipReason(REDIS_URL) }, () => { + let store; + let projection; + let redis; + let connected = false; + + before(async () => { + assertRedisIsolationOrThrow(REDIS_URL, 'RedisRoutingFactProjection'); + const storeModule = await import('../dist/domains/cats/services/stores/redis/RedisMessageStore.js'); + const projModule = await import('../dist/domains/cats/services/stores/redis/RedisRoutingFactProjection.js'); + const redisModule = await import('@cat-cafe/shared/utils'); + redis = redisModule.createRedisClient({ url: REDIS_URL, keyPrefix: TEST_KEY_PREFIX }); + try { + await redis.ping(); + connected = true; + } catch { + await redis.quit().catch(() => {}); + return; + } + store = new storeModule.RedisMessageStore(redis); + projection = new projModule.RedisRoutingFactProjection(redis); + }); + + after(async () => { + if (redis && connected) { + await cleanupClientKeyspace(redis); + await redis.quit(); + } + }); + + beforeEach(async (t) => { + if (!connected) return t.skip('Redis not connected'); + await cleanupClientKeyspace(redis); + }); + + async function appendFactMessage(batch, timestamp, extra = {}) { + return store.append({ + userId: OWNER, + catId: batch.parserMode === 'a2a' ? 'opus' : null, + content: 'seed', + mentions: [], + timestamp, + threadId: 'th-f257-proj', + routingFact: batch, + // writer-declared three-axis provenance (author / routed / observation) + provenance: { author: batch.parserMode === 'a2a' ? 'cat' : 'user', routed: true, observation: 'original' }, + ...extra, + }); + } + + it('project() indexes a fact message and advances the watermark monotonically', async () => { + const now = Date.now(); + const m1 = await appendFactMessage(a2aBatch(), now - 1000); + const m2 = await appendFactMessage(userBatch(), now); + // project out of order — watermark must end at the max id + await projection.project(m2); + await projection.project(m1); + const members = await redis.zrangebyscore(`routing-fact:idx:${OWNER}`, now - 2000, now + 1); + assert.deepEqual(new Set(members), new Set([m1.id, m2.id])); + const watermark = await redis.get(`routing-fact:watermark:${OWNER}`); + assert.equal(watermark, m2.id > m1.id ? m2.id : m1.id); + const health = await projection.getHealth(OWNER); + assert.equal(health.ok, true); + assert.equal(health.errorCount, 0); + }); + + it('project() is a no-op for messages without a fact', async () => { + const now = Date.now(); + const msg = await store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: OWNER, + catId: null, + content: 'no tokens', + mentions: [], + timestamp: now, + threadId: 'th-f257-proj', + }); + await projection.project(msg); + const members = await redis.zrangebyscore(`routing-fact:idx:${OWNER}`, now - 1, now + 1); + assert.deepEqual(members, []); + }); + + it('reconcileWindow() rebuilds missing projection entries from authority records (idempotent)', async () => { + const now = Date.now(); + await appendFactMessage(a2aBatch(), now - 500); + await appendFactMessage(userBatch(), now - 400); + // briefing-origin messages are outside the routable cohort — no fact expected + await store.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, + userId: OWNER, + catId: null, + content: 'no fact', + mentions: [], + timestamp: now - 300, + threadId: 'th-f257-proj', + origin: 'briefing', + }); + + // No projector ran — projection is empty; reconcile must rebuild from authority. + const first = await projection.reconcileWindow(OWNER, now - 1000, now); + assert.equal(first.ok, true); + assert.equal(first.cohortCount, 2, 'briefing message is out of cohort'); + assert.equal(first.authorityCount, 2); + assert.equal(first.producerGapCount, 0); + assert.equal(first.repairedMissing, 2); + assert.equal(first.removedStale, 0); + + const second = await projection.reconcileWindow(OWNER, now - 1000, now); + assert.equal(second.ok, true); + assert.equal(second.repairedMissing, 0, 'idempotent — nothing left to repair'); + assert.equal(second.projectedCount, 2); + }); + + it('R7: queued routed message remains measurable in delivery-time windows and after owner reassignment', async () => { + const deliveredAt = Date.now(); + const nextOwner = `${OWNER}-reassigned`; + const msg = await appendFactMessage(userBatch(), deliveredAt - 60_000, { deliveryStatus: 'queued' }); + await store.markDelivered(msg.id, deliveredAt); + + const delivered = await projection.computeResolutionRate(OWNER, deliveredAt - 100, deliveredAt + 100); + assert.equal(delivered.unmeasurable, false); + assert.equal(delivered.coverage.cohortCount, 1); + + await store.reassignUserId(msg.id, nextOwner); + const reassigned = await projection.computeResolutionRate(nextOwner, deliveredAt - 100, deliveredAt + 100); + assert.equal(reassigned.unmeasurable, false); + assert.equal(reassigned.coverage.cohortCount, 1); + const oldOwner = await projection.computeResolutionRate(OWNER, deliveredAt - 100, deliveredAt + 100); + assert.equal(oldOwner.unmeasurable, false); + assert.equal(oldOwner.coverage.cohortCount, 0); + }); + + it('R11: reassign before delayed delivery converges on the current owner and delivery coordinate', async () => { + const sentAt = Date.now() - 10_000; + const deliveredAt = sentAt + 5_000; + const nextOwner = `${OWNER}-r11-reassign-first`; + const msg = await appendFactMessage(userBatch(), sentAt, { deliveryStatus: 'queued' }); + + const originalEval = redis.eval.bind(redis); + let announceDeliveryCommit; + let releaseDeliveryCommit; + const deliveryCommit = new Promise((resolve) => { + announceDeliveryCommit = resolve; + }); + const deliveryRelease = new Promise((resolve) => { + releaseDeliveryCommit = resolve; + }); + let pauseDelivery = true; + redis.eval = async (...args) => { + const script = String(args[0] ?? ''); + if (pauseDelivery && script.includes("'deliveryStatus', 'delivered'")) { + pauseDelivery = false; + announceDeliveryCommit(); + await deliveryRelease; + } + return originalEval(...args); + }; + + try { + const delivery = store.markDelivered(msg.id, deliveredAt); + await deliveryCommit; + assert.ok(await store.reassignUserId(msg.id, nextOwner)); + releaseDeliveryCommit(); + assert.ok(await delivery); + } finally { + releaseDeliveryCommit?.(); + redis.eval = originalEval; + } + + assert.deepEqual(await redis.hmget(`msg:${msg.id}`, 'userId', 'deliveredAt'), [nextOwner, String(deliveredAt)]); + assert.equal(await redis.zscore(`msg:user:${OWNER}`, msg.id), null, 'delivery must not recreate old owner'); + assert.equal(await redis.zscore(`msg:user:${nextOwner}`, msg.id), String(deliveredAt)); + assert.equal(await redis.zscore('msg:timeline', msg.id), String(deliveredAt)); + assert.equal(await redis.zscore(`msg:thread:${msg.threadId}`, msg.id), String(deliveredAt)); + + const rate = await projection.computeResolutionRate(nextOwner, deliveredAt - 100, deliveredAt + 100); + assert.equal(rate.unmeasurable, false); + assert.equal(rate.coverage.cohortCount, 1, 'delivery-time exact cohort must include the reassigned message'); + }); + + it('R11: delivery before delayed reassign moves the commit-time effective order to the new owner', async () => { + const sentAt = Date.now() - 10_000; + const deliveredAt = sentAt + 5_000; + const nextOwner = `${OWNER}-r11-delivery-first`; + const msg = await appendFactMessage(userBatch(), sentAt, { deliveryStatus: 'queued' }); + + const originalEval = redis.eval.bind(redis); + let announceReassignCommit; + let releaseReassignCommit; + const reassignCommit = new Promise((resolve) => { + announceReassignCommit = resolve; + }); + const reassignRelease = new Promise((resolve) => { + releaseReassignCommit = resolve; + }); + let pauseReassign = true; + redis.eval = async (...args) => { + const script = String(args[0] ?? ''); + if (pauseReassign && script.includes("'userId', nextUserId")) { + pauseReassign = false; + announceReassignCommit(); + await reassignRelease; + } + return originalEval(...args); + }; + + try { + const reassignment = store.reassignUserId(msg.id, nextOwner); + await reassignCommit; + assert.ok(await store.markDelivered(msg.id, deliveredAt)); + releaseReassignCommit(); + const reassigned = await reassignment; + assert.ok(reassigned); + assert.equal(reassigned.userId, nextOwner); + assert.equal(reassigned.deliveryStatus, 'delivered', 'return value must reflect commit-time authority state'); + assert.equal(reassigned.deliveredAt, deliveredAt, 'return value must include commit-time effective order'); + } finally { + releaseReassignCommit?.(); + redis.eval = originalEval; + } + + assert.deepEqual(await redis.hmget(`msg:${msg.id}`, 'userId', 'deliveredAt'), [nextOwner, String(deliveredAt)]); + assert.equal(await redis.zscore(`msg:user:${OWNER}`, msg.id), null); + assert.equal( + await redis.zscore(`msg:user:${nextOwner}`, msg.id), + String(deliveredAt), + 'reassign must derive score from authority at Lua commit time', + ); + assert.equal(await redis.zscore('msg:timeline', msg.id), String(deliveredAt)); + assert.equal(await redis.zscore(`msg:thread:${msg.threadId}`, msg.id), String(deliveredAt)); + + const rate = await projection.computeResolutionRate(nextOwner, deliveredAt - 100, deliveredAt + 100); + assert.equal(rate.unmeasurable, false); + assert.equal(rate.coverage.cohortCount, 1, 'delivery-time exact cohort must include the reassigned message'); + }); + + it('R11: a stale projector snapshot derives routing score from commit-time effective order', async () => { + const sentAt = Date.now() - 10_000; + const deliveredAt = sentAt + 5_000; + const msg = await appendFactMessage(userBatch(), sentAt, { deliveryStatus: 'queued' }); + + assert.ok(await store.markDelivered(msg.id, deliveredAt)); + await projection.project(msg); + + assert.equal( + await redis.zscore(`routing-fact:idx:${OWNER}`, msg.id), + String(deliveredAt), + 'projector must not restore the stale sentAt score after delivery', + ); + }); + + it('R12: same-owner reassignment returns authority changes that committed after its pre-read', async () => { + const sentAt = Date.now() - 10_000; + const deliveredAt = sentAt + 5_000; + const msg = await appendFactMessage(userBatch(), sentAt, { deliveryStatus: 'queued' }); + + const originalEval = redis.eval.bind(redis); + let announceNoopCommit; + let releaseNoopCommit; + const noopCommit = new Promise((resolve) => { + announceNoopCommit = resolve; + }); + const noopRelease = new Promise((resolve) => { + releaseNoopCommit = resolve; + }); + let pauseNoop = true; + redis.eval = async (...args) => { + const script = String(args[0] ?? ''); + if (pauseNoop && script.includes('curUserId == nextUserId')) { + pauseNoop = false; + announceNoopCommit(); + await noopRelease; + } + return originalEval(...args); + }; + + try { + const reassignment = store.reassignUserId(msg.id, OWNER); + await noopCommit; + assert.ok(await store.markDelivered(msg.id, deliveredAt)); + releaseNoopCommit(); + const reassigned = await reassignment; + assert.ok(reassigned); + assert.equal(reassigned.userId, OWNER); + assert.equal(reassigned.deliveryStatus, 'delivered'); + assert.equal(reassigned.deliveredAt, deliveredAt); + } finally { + releaseNoopCommit?.(); + redis.eval = originalEval; + } + }); + + it('R12: projection-first delivery converges at the reconcile-before-evaluate boundary', async () => { + const sentAt = Date.now() - 10_000; + const deliveredAt = sentAt + 5_000; + const msg = await appendFactMessage(userBatch(), sentAt, { deliveryStatus: 'queued' }); + + await projection.project(msg); + assert.equal(await redis.zscore(`routing-fact:idx:${OWNER}`, msg.id), String(sentAt)); + + assert.ok(await store.markDelivered(msg.id, deliveredAt)); + assert.equal( + await redis.zscore(`routing-fact:idx:${OWNER}`, msg.id), + String(sentAt), + 'async query projection may remain stale until the mandatory reconcile boundary', + ); + + const coverage = await projection.reconcileWindow(OWNER, deliveredAt - 100, deliveredAt + 100); + assert.equal(coverage.ok, true); + assert.equal(coverage.repairedMissing, 1); + assert.equal(await redis.zscore(`routing-fact:idx:${OWNER}`, msg.id), String(deliveredAt)); + }); + + it('reconcileWindow() flags a routed message without a fact as a producer gap (sol R1/R3 P1-1)', async () => { + const now = Date.now(); + await appendFactMessage(userBatch(), now - 500); + // The append boundary enforces routed ⇔ fact, so a gap can only come from an + // out-of-band write or a broken producer — simulate one by corrupting the + // provenance field after a legal surface append. + const broken = await store.append({ + userId: OWNER, + catId: null, + content: '@opus 看下', + mentions: ['opus'], + timestamp: now - 400, + threadId: 'th-f257-proj', + provenance: { author: 'user', routed: false, observation: 'original' }, + }); + await redis.hset(`msg:${broken.id}`, { + provenance: JSON.stringify({ author: 'user', routed: true, observation: 'original' }), + }); + + const coverage = await projection.reconcileWindow(OWNER, now - 1000, now); + assert.equal(coverage.ok, false, 'producer gap must not report a healthy window'); + assert.equal(coverage.reason, 'producer_gap'); + assert.equal(coverage.cohortCount, 2); + assert.equal(coverage.authorityCount, 1); + assert.equal(coverage.producerGapCount, 1); + + const rate = await projection.computeResolutionRate(OWNER, now - 1000, now); + assert.equal(rate.unmeasurable, true); + assert.equal(rate.reason, 'producer_gap'); + assert.equal(rate.coverage.producerGapCount, 1); + }); + + it('surface messages without a routed lane are out of cohort (sol R2 P1-1 repro)', async () => { + const now = Date.now(); + await appendFactMessage(userBatch(), now - 500); + // sol repro: a normal proposal rich card — owner userId, catId null, no + // source, NO lane declaration — previously misjudged as a producer gap. + await store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: OWNER, + catId: null, + content: '📋 新 thread 提案卡片', + mentions: [], + timestamp: now - 450, + threadId: 'th-f257-proj', + extra: { rich: { v: 1, blocks: [] } }, + }); + // system-notice shape (source-carrying), also lane-less + await store.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, + userId: OWNER, + catId: null, + content: '服务刚重启,请重新发送。', + mentions: [], + timestamp: now - 400, + threadId: 'th-f257-proj', + source: { connector: 'startup-reconciler', label: '重启提醒', icon: '🔄' }, + }); + + const coverage = await projection.reconcileWindow(OWNER, now - 1000, now); + assert.equal(coverage.ok, true, 'surface messages must not count as producer gaps'); + assert.equal(coverage.cohortCount, 1, 'only the routed-lane message is in cohort'); + assert.equal(coverage.producerGapCount, 0); + + const rate = await projection.computeResolutionRate(OWNER, now - 1000, now); + assert.equal(rate.unmeasurable, false, 'window with surface messages stays measurable'); + }); + + it('zero-token batches persist and count as authority (producer-run marker, sol R1 P1-1)', async () => { + const now = Date.now(); + const msg = await appendFactMessage(userBatch({ attempts: [] }), now - 200); + await projection.project(msg); + const members = await redis.zrangebyscore(`routing-fact:idx:${OWNER}`, now - 300, now); + assert.deepEqual(members, [msg.id], 'empty batch is indexed'); + + const coverage = await projection.reconcileWindow(OWNER, now - 1000, now); + assert.equal(coverage.ok, true); + assert.equal(coverage.cohortCount, 1); + assert.equal(coverage.authorityCount, 1); + assert.equal(coverage.producerGapCount, 0); + }); + + it('sol R4 P1-1c: malformed provenance -> window unmeasurable; absent legacy -> measurable, out of cohort', async () => { + const now = Date.now(); + await appendFactMessage(userBatch(), now - 500); + const bad = await store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, + userId: OWNER, + catId: null, + content: 'surface message', + mentions: [], + timestamp: now - 400, + threadId: 'th-f257-proj', + }); + // storage fault repro (sol R4): corrupt the persisted declaration + await redis.hset(`msg:${bad.id}`, { provenance: '{"author":"user"' }); + const rec = await projection.reconcileWindow(OWNER, now - 1000, now); + assert.equal(rec.ok, false, 'corrupt declaration = cohort boundary unknowable'); + assert.equal(rec.reason, 'malformed_provenance'); + + // absent (legacy pre-contract) is a DIFFERENT fact: window stays measurable + await redis.hdel(`msg:${bad.id}`, 'provenance'); + const rec2 = await projection.reconcileWindow(OWNER, now - 1000, now); + assert.equal(rec2.ok, true); + assert.equal(rec2.cohortCount, 1, 'legacy message honestly out of cohort'); + // out-of-domain author is malformed too, not silently non-routed + await redis.hset(`msg:${bad.id}`, { + provenance: JSON.stringify({ author: 'ghost', routed: true, observation: 'original' }), + }); + const rec3 = await projection.reconcileWindow(OWNER, now - 1000, now); + assert.equal(rec3.ok, false); + assert.equal(rec3.reason, 'malformed_provenance'); + }); + + it('R5: missing detail hash is a collection gap, never a healthy legacy-sized window', async () => { + const now = Date.now(); + const msg = await appendFactMessage(userBatch(), now - 500); + await redis.del(`msg:${msg.id}`); // owner timeline survives; authority hash does not + + const coverage = await projection.reconcileWindow(OWNER, now - 1000, now); + assert.equal(coverage.ok, false); + assert.equal(coverage.reason, 'collection_gap'); + + const rate = await projection.computeResolutionRate(OWNER, now - 1000, now); + assert.equal(rate.unmeasurable, true); + assert.equal(rate.reason, 'reconcile_failed'); + }); + + it('R5: persisted routingFact/provenance contradictions make the window unmeasurable', async () => { + const now = Date.now(); + const msg = await appendFactMessage(userBatch(), now - 500); + await redis.hset( + `msg:${msg.id}`, + 'provenance', + JSON.stringify({ author: 'user', routed: false, observation: 'original' }), + ); + + const coverage = await projection.reconcileWindow(OWNER, now - 1000, now); + assert.equal(coverage.ok, false, 'fact present + routed:false cannot silently leave the cohort'); + assert.equal(coverage.reason, 'malformed_provenance'); + + await redis.hdel(`msg:${msg.id}`, 'provenance'); + const undeclaredFact = await projection.reconcileWindow(OWNER, now - 1000, now); + assert.equal(undeclaredFact.ok, false, 'a fact without any provenance declaration is not a legacy surface row'); + assert.equal(undeclaredFact.reason, 'malformed_provenance'); + }); + + it('R6: empty or malformed routingFact fails during canonical reconcile', async () => { + const now = Date.now(); + const msg = await appendFactMessage(userBatch(), now - 500); + + await redis.hset(`msg:${msg.id}`, 'routingFact', ''); + const empty = await projection.reconcileWindow(OWNER, now - 1000, now); + assert.equal(empty.ok, false); + assert.equal(empty.reason, 'malformed_authority_fact'); + + await redis.hset(`msg:${msg.id}`, 'routingFact', '{'); + const malformed = await projection.reconcileWindow(OWNER, now - 1000, now); + assert.equal(malformed.ok, false); + assert.equal(malformed.reason, 'malformed_authority_fact'); + }); + + it('R8: soft-deleted routed authority is excluded until restore', async () => { + const now = Date.now(); + const msg = await appendFactMessage(userBatch(), now - 500); + await projection.project(msg); + + await store.softDelete(msg.id, OWNER); + const deleted = await projection.reconcileWindow(OWNER, now - 1000, now); + assert.equal(deleted.ok, true); + assert.equal(deleted.cohortCount, 0); + assert.equal(deleted.authorityCount, 0); + assert.equal(deleted.removedStale, 1); + + await store.restore(msg.id); + const restored = await projection.reconcileWindow(OWNER, now - 1000, now); + assert.equal(restored.ok, true); + assert.equal(restored.cohortCount, 1); + assert.equal(restored.authorityCount, 1); + assert.equal(restored.repairedMissing, 1); + }); + + it('R8: hard delete scrubs embedded authority and its query projection', async () => { + const now = Date.now(); + const msg = await appendFactMessage(userBatch(), now - 500); + await projection.project(msg); + + await store.hardDelete(msg.id, OWNER); + + assert.equal(await redis.hget(`msg:${msg.id}`, 'routingFact'), null); + assert.equal(await redis.hget(`msg:${msg.id}`, 'provenance'), null); + assert.deepEqual(await redis.zrange(`routing-fact:idx:${OWNER}`, 0, -1), []); + const result = await projection.reconcileWindow(OWNER, now - 1000, now); + assert.equal(result.ok, true); + assert.equal(result.cohortCount, 0); + assert.equal(result.authorityCount, 0); + }); + + it('R8: physical thread deletion removes owner and routing projections atomically enough for exact reads', async () => { + const now = Date.now(); + const msg = await appendFactMessage(userBatch(), now - 500); + await projection.project(msg); + + assert.equal(await store.deleteByThread(msg.threadId), 1); + + assert.deepEqual(await redis.zrange(`msg:user:${OWNER}`, 0, -1), []); + assert.deepEqual(await redis.zrange(`routing-fact:idx:${OWNER}`, 0, -1), []); + const result = await projection.reconcileWindow(OWNER, now - 1000, now); + assert.equal(result.ok, true); + assert.equal(result.cohortCount, 0); + }); + + it('R10: wired delayed projectors cannot resurrect routing state after hard or physical deletion', async () => { + const storeModule = await import('../dist/domains/cats/services/stores/redis/RedisMessageStore.js'); + + async function runDeletionRace(kind) { + let announceStarted; + let releaseProjection; + let announceFinished; + const started = new Promise((resolve) => { + announceStarted = resolve; + }); + const released = new Promise((resolve) => { + releaseProjection = resolve; + }); + const finished = new Promise((resolve) => { + announceFinished = resolve; + }); + const delayedProjector = { + async project(snapshot) { + announceStarted(); + await released; + try { + await projection.project(snapshot); + } finally { + announceFinished(); + } + }, + }; + const wiredStore = new storeModule.RedisMessageStore(redis, { routingFactProjection: delayedProjector }); + const now = Date.now(); + const msg = await wiredStore.append({ + provenance: { author: 'user', routed: true, observation: 'original' }, + userId: OWNER, + catId: null, + content: '@opus delayed projection', + mentions: ['opus'], + timestamp: now, + threadId: `th-f257-r10-${kind}`, + routingFact: userBatch(), + }); + await started; + + if (kind === 'hard') { + await wiredStore.hardDelete(msg.id, OWNER); + } else { + assert.equal(await wiredStore.deleteByThread(msg.threadId), 1); + } + releaseProjection(); + await finished; + + assert.equal(await redis.zscore(`routing-fact:idx:${OWNER}`, msg.id), null, `${kind} delete stays terminal`); + assert.equal(await redis.zscore(`routing-fact:proj-errors:${OWNER}`, msg.id), null); + } + + await runDeletionRace('hard'); + await runDeletionRace('physical'); + }); + + it('R10: a delayed reconcile repair cannot resurrect routing state after hard delete', async () => { + const now = Date.now(); + const msg = await appendFactMessage(userBatch(), now); + await redis.zrem(`routing-fact:idx:${OWNER}`, msg.id); + + const originalRangeByScore = redis.zrangebyscore.bind(redis); + let announceProjectionRead; + let releaseProjectionRead; + const projectionRead = new Promise((resolve) => { + announceProjectionRead = resolve; + }); + const projectionRelease = new Promise((resolve) => { + releaseProjectionRead = resolve; + }); + redis.zrangebyscore = async (key, ...args) => { + const result = await originalRangeByScore(key, ...args); + if (key === `routing-fact:idx:${OWNER}`) { + announceProjectionRead(); + await projectionRelease; + } + return result; + }; + + try { + const reconcile = projection.reconcileWindow(OWNER, now - 1, now + 1); + await projectionRead; + assert.ok(await store.hardDelete(msg.id, OWNER)); + releaseProjectionRead(); + await reconcile; + } finally { + redis.zrangebyscore = originalRangeByScore; + } + + assert.equal(await redis.zscore(`routing-fact:idx:${OWNER}`, msg.id), null); + assert.equal(await redis.zscore(`routing-fact:proj-errors:${OWNER}`, msg.id), null); + }); + + it('R10: physical delete cleans a projection created after its initial sibling scan', async () => { + const now = Date.now(); + const msg = await appendFactMessage(userBatch(), now); + await redis.del(`routing-fact:idx:${OWNER}`); + + const originalMulti = redis.multi.bind(redis); + let announceDeleteCommit; + let releaseDeleteCommit; + const deleteCommit = new Promise((resolve) => { + announceDeleteCommit = resolve; + }); + const deleteRelease = new Promise((resolve) => { + releaseDeleteCommit = resolve; + }); + let pauseNextMulti = true; + redis.multi = (...args) => { + const transaction = originalMulti(...args); + if (pauseNextMulti) { + pauseNextMulti = false; + const originalExec = transaction.exec.bind(transaction); + transaction.exec = async () => { + announceDeleteCommit(); + await deleteRelease; + return originalExec(); + }; + } + return transaction; + }; + + try { + const deletion = store.deleteByThread(msg.threadId); + await deleteCommit; + await projection.project(msg); + assert.ok(await redis.zscore(`routing-fact:idx:${OWNER}`, msg.id)); + releaseDeleteCommit(); + assert.equal(await deletion, 1); + } finally { + redis.multi = originalMulti; + } + + assert.equal(await redis.exists(`msg:${msg.id}`), 0); + assert.equal(await redis.zscore(`routing-fact:idx:${OWNER}`, msg.id), null); + }); + + it('R10: hard delete cleans the authority owner that wins a concurrent reassignment', async () => { + const nextOwner = `${OWNER}-r10-reassigned`; + const msg = await appendFactMessage(userBatch(), Date.now()); + + const originalGetById = store.getById.bind(store); + let firstRead = true; + let announceHardRead; + let releaseHardRead; + const hardRead = new Promise((resolve) => { + announceHardRead = resolve; + }); + const hardRelease = new Promise((resolve) => { + releaseHardRead = resolve; + }); + store.getById = async (id) => { + const value = await originalGetById(id); + if (firstRead) { + firstRead = false; + announceHardRead(); + await hardRelease; + } + return value; + }; + + try { + const hardDelete = store.hardDelete(msg.id, OWNER); + await hardRead; + assert.ok(await store.reassignUserId(msg.id, nextOwner)); + const reassigned = await originalGetById(msg.id); + await projection.project(reassigned); + assert.ok(await redis.zscore(`routing-fact:idx:${nextOwner}`, msg.id)); + releaseHardRead(); + assert.ok(await hardDelete); + } finally { + store.getById = originalGetById; + } + + assert.equal(await redis.zscore(`routing-fact:idx:${OWNER}`, msg.id), null); + assert.equal(await redis.zscore(`routing-fact:idx:${nextOwner}`, msg.id), null); + }); + + it('R5: an empty persisted provenance field is malformed, not absent legacy data', async () => { + const now = Date.now(); + const msg = await appendFactMessage(userBatch(), now - 500); + await redis.hset(`msg:${msg.id}`, 'provenance', ''); + + const coverage = await projection.reconcileWindow(OWNER, now - 1000, now); + assert.equal(coverage.ok, false); + assert.equal(coverage.reason, 'malformed_provenance'); + }); + + it('reconcileWindow() removes stale projection members with no authority record', async () => { + const now = Date.now(); + await redis.zadd(`routing-fact:idx:${OWNER}`, String(now - 100), 'ghost-message-id'); + const result = await projection.reconcileWindow(OWNER, now - 1000, now); + assert.equal(result.ok, true); + assert.equal(result.removedStale, 1); + const members = await redis.zrangebyscore(`routing-fact:idx:${OWNER}`, now - 1000, now); + assert.deepEqual(members, []); + }); + + it('computeResolutionRate() aggregates per parserMode per T-A columns, excluding ineligible batches', async () => { + const now = Date.now(); + // a2a: eligible attempts = resolved + unknown_token (duplicate excluded) → 1/2 + await appendFactMessage(a2aBatch(), now - 900); + // user: resolved → 1/1 + await appendFactMessage(userBatch(), now - 800); + // truncated a2a batch (metricEligible=false) — excluded entirely per T-A (右截断) + await appendFactMessage(a2aBatch({ truncated: true, metricEligible: false }), now - 700); + + const result = await projection.computeResolutionRate(OWNER, now - 1000, now); + assert.equal(result.unmeasurable, false); + assert.equal(result.modes.a2a.numerator, 1); + assert.equal(result.modes.a2a.denominator, 2); + assert.equal(result.modes.a2a.rate, 0.5); + assert.equal(result.modes.a2a.batches, 1); + assert.equal(result.modes.user.numerator, 1); + assert.equal(result.modes.user.denominator, 1); + assert.equal(result.modes.user.rate, 1); + assert.equal(result.excludedBatches, 1); + assert.equal(result.malformedFacts, 0); + assert.equal(result.coverage.authorityCount, 3, 'coverage counts all fact-carrying messages'); + }); + + it('computeResolutionRate() reports empty windows as measurable with null rates', async () => { + const now = Date.now(); + const result = await projection.computeResolutionRate(OWNER, now - 1000, now); + assert.equal(result.unmeasurable, false); + assert.equal(result.modes.a2a.rate, null); + assert.equal(result.modes.user.rate, null); + assert.equal(result.modes.a2a.denominator, 0); + }); + + it('RedisMessageStore append() drives the wired projector automatically', async () => { + const storeModule = await import('../dist/domains/cats/services/stores/redis/RedisMessageStore.js'); + const wiredStore = new storeModule.RedisMessageStore(redis, { routingFactProjection: projection }); + const now = Date.now(); + const msg = await wiredStore.append({ + provenance: { author: 'user', routed: true, observation: 'original' }, + userId: OWNER, + catId: null, + content: '@opus 看下', + mentions: ['opus'], + timestamp: now, + threadId: 'th-f257-wired', + routingFact: userBatch(), + }); + // project() is fired void — give the microtask queue a beat + await new Promise((resolve) => setTimeout(resolve, 50)); + const members = await redis.zrangebyscore(`routing-fact:idx:${OWNER}`, now - 1, now + 1); + assert.deepEqual(members, [msg.id]); + const watermark = await redis.get(`routing-fact:watermark:${OWNER}`); + assert.equal(watermark, msg.id); + }); + + it('computeResolutionRate() forces unmeasurable when an authority fact is malformed (sol R1 P1-3)', async () => { + const now = Date.now(); + await appendFactMessage(a2aBatch(), now - 600); + const msg = await appendFactMessage(userBatch(), now - 500); + await redis.hset(`msg:${msg.id}`, { routingFact: '{broken json' }); + const result = await projection.computeResolutionRate(OWNER, now - 1000, now); + assert.equal(result.unmeasurable, true, 'no partial rate over a half-parseable window'); + assert.equal(result.reason, 'malformed_authority_fact'); + assert.equal(result.malformedFacts, 1); + }); + + it('deep validation rejects parseable-but-invalid facts (unknown outcome → malformed, sol R1 P1-3)', async () => { + const now = Date.now(); + const invalid = userBatch({ + attempts: [{ tokenOrdinal: 0, outcome: 'not_a_real_outcome', token: '@x', span: { start: 0, end: 2 } }], + }); + const msg = await appendFactMessage(invalid, now - 500); + assert.ok(msg.id); + const result = await projection.computeResolutionRate(OWNER, now - 1000, now); + assert.equal(result.unmeasurable, true); + assert.equal(result.reason, 'malformed_authority_fact'); + }); + + it('project() surfaces per-command MULTI errors: no watermark advance, error marker written (sol R1 P1-5)', async () => { + const now = Date.now(); + // Break the index key type so ZADD fails as a per-command error + await redis.set(`routing-fact:idx:${OWNER}`, 'wrong-type'); + const msg = await appendFactMessage(userBatch(), now - 100); + await projection.project(msg); + + const watermark = await redis.get(`routing-fact:watermark:${OWNER}`); + assert.equal(watermark, null, 'watermark must not advance over a failed write'); + const health = await projection.getHealth(OWNER); + assert.equal(health.errorCount, 1, 'failure lands in the error ZSET (visible)'); + + const coverage = await projection.reconcileWindow(OWNER, now - 1000, now); + assert.equal(coverage.ok, false, 'wrong-type index cannot reconcile silently'); + }); +}); diff --git a/packages/api/test/reminder-template.test.js b/packages/api/test/reminder-template.test.js index 48181188bc..096a4d0431 100644 --- a/packages/api/test/reminder-template.test.js +++ b/packages/api/test/reminder-template.test.js @@ -121,6 +121,42 @@ describe('reminderTemplate', () => { assert.equal(triggerMock.trigger.mock.calls[0].arguments[1], 'sonnet'); }); + it('F257 LI-001: hold-ball reminder opts into action-or-routing-exit completion', async () => { + const deliverMock = mock.fn(async () => 'msg-hold-wake'); + const triggerMock = { trigger: mock.fn() }; + const spec = reminderTemplate.createSpec('hold-ball-1748000000-liveness', { + trigger: { type: 'once', fireAt: Date.now() + 60_000 }, + params: { message: '持球唤醒', targetCatId: 'gpt52' }, + deliveryThreadId: 'th-hold-liveness', + }); + + await spec.run.execute('持球唤醒', 'thread-th-hold-liveness', { + assignedCatId: null, + deliver: deliverMock, + invokeTrigger: triggerMock, + }); + + assert.equal(triggerMock.trigger.mock.calls[0].arguments[6]?.completionRequirement, 'action-or-routing-exit'); + }); + + it('F257 LI-001: ordinary reminder does not opt into action liveness', async () => { + const deliverMock = mock.fn(async () => 'msg-normal-reminder'); + const triggerMock = { trigger: mock.fn() }; + const spec = reminderTemplate.createSpec('dyn-1748000000-normal', { + trigger: { type: 'once', fireAt: Date.now() + 60_000 }, + params: { message: 'ordinary reminder', targetCatId: 'gpt52' }, + deliveryThreadId: 'th-normal-reminder', + }); + + await spec.run.execute('ordinary reminder', 'thread-th-normal-reminder', { + assignedCatId: null, + deliver: deliverMock, + invokeTrigger: triggerMock, + }); + + assert.equal(triggerMock.trigger.mock.calls[0].arguments[6]?.completionRequirement, undefined); + }); + it('uses default message when param is empty', async () => { const deliverMock = mock.fn(async () => 'msg-3'); const spec = reminderTemplate.createSpec('rem-6', { @@ -186,3 +222,52 @@ describe('reminderTemplate firePolicy activation guard (F167 Phase M — codex P assert.equal(spec.firePolicy, undefined); }); }); + +describe('reminderTemplate — once-trigger idempotency (sol P1 regression收口)', () => { + it('once trigger passes a per-instance idempotencyKey to deliver (bounded-retry safe)', async () => { + const deliverMock = mock.fn(async () => 'msg-once'); + const spec = reminderTemplate.createSpec('hold-ball-1748000000-idem', { + trigger: { type: 'once', fireAt: Date.now() + 60_000 }, + params: { message: 'wake' }, + deliveryThreadId: 'th-idem', + }); + await spec.run.execute('wake', 'thread-th-idem', { assignedCatId: null, deliver: deliverMock }); + assert.equal(deliverMock.mock.calls[0].arguments[0].idempotencyKey, 'reminder:hold-ball-1748000000-idem'); + }); + + it('cron trigger does NOT pass idempotencyKey (each slot is a distinct firing)', async () => { + const deliverMock = mock.fn(async () => 'msg-cron'); + const spec = reminderTemplate.createSpec('rem-cron-idem', { + trigger: { type: 'cron', expression: '0 9 * * *' }, + params: { message: '喝水提醒' }, + deliveryThreadId: 'th-cron-idem', + }); + await spec.run.execute('喝水提醒', 'thread-th-cron-idem', { assignedCatId: null, deliver: deliverMock }); + assert.equal(deliverMock.mock.calls[0].arguments[0].idempotencyKey, undefined); + }); + + it('REGRESSION red→green: hold-ball once wake persists with system provenance via real store', async () => { + // 2026-07-20 → 23 incident: this exact path threw `append requires + // provenance` at the write boundary (RUN_FAILED → silent retire → lost + // hold-ball wake). End-to-end through the REAL in-memory MessageStore. + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + const { createDeliverFn } = await import('../dist/infrastructure/scheduler/delivery.js'); + const messageStore = new MessageStore(); + const deliver = createDeliverFn({ + messageStore, + socketManager: { broadcastToRoom: () => {}, emitToUser: () => {} }, + }); + const spec = reminderTemplate.createSpec('hold-ball-1748000000-e2e', { + trigger: { type: 'once', fireAt: Date.now() + 60_000 }, + params: { message: '持球唤醒' }, + deliveryThreadId: 'th-hold-e2e', + }); + + await spec.run.execute('持球唤醒', 'thread-th-hold-e2e', { assignedCatId: null, deliver }); + + const messages = messageStore.getByThread('th-hold-e2e'); + assert.equal(messages.length, 1); + assert.deepEqual(messages[0].provenance, { author: 'system', routed: false, observation: 'original' }); + assert.equal(messages[0].content, `${SCHEDULER_TRIGGER_PREFIX} 持球唤醒`); + }); +}); diff --git a/packages/api/test/reply-to-threading.test.js b/packages/api/test/reply-to-threading.test.js index ce83651bd3..d29266a504 100644 --- a/packages/api/test/reply-to-threading.test.js +++ b/packages/api/test/reply-to-threading.test.js @@ -14,6 +14,7 @@ describe('replyTo threading', () => { const store = new MessageStore(); const parent = store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'opus', content: 'Original message', @@ -23,6 +24,7 @@ describe('replyTo threading', () => { }); const reply = store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'codex', content: 'Reply to original', @@ -42,6 +44,7 @@ describe('replyTo threading', () => { const store = new MessageStore(); const msg = store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'No reply', @@ -57,6 +60,7 @@ describe('replyTo threading', () => { const store = new MessageStore(); const parent = store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'opus', content: 'Parent', @@ -66,6 +70,7 @@ describe('replyTo threading', () => { }); store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'codex', content: 'Child', @@ -89,6 +94,7 @@ describe('replyTo threading', () => { const store = new MessageStore(); const parent = store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'opus', content: '这是一条很长的消息,需要被截断到八十个字符以内来显示预览内容,确保在引用气泡中不会太长影响阅读体验', @@ -111,6 +117,7 @@ describe('replyTo threading', () => { const store = new MessageStore(); const parent = store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'opus', content: 'Will be deleted', @@ -144,6 +151,7 @@ describe('replyTo threading', () => { const store = new MessageStore(); const parent = store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'User message', diff --git a/packages/api/test/reply-to-validation.test.js b/packages/api/test/reply-to-validation.test.js index 18f3f7418f..8629e2ebc9 100644 --- a/packages/api/test/reply-to-validation.test.js +++ b/packages/api/test/reply-to-validation.test.js @@ -57,6 +57,13 @@ describe('POST /api/messages — replyTo validation', () => { }, router: { resolveTargetsAndIntent: mock.fn(async () => ({ + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, targetCats: ['opus'], intent: { intent: 'execute' }, })), @@ -139,6 +146,7 @@ describe('POST /api/messages — replyTo validation', () => { test('silently drops replyTo referencing system message', async () => { const thread = await createThread(); const sysMsg = messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, userId: 'system', catId: null, content: 'SYSTEM BADGE — internal', @@ -160,6 +168,7 @@ describe('POST /api/messages — replyTo validation', () => { test('silently drops replyTo referencing briefing message', async () => { const thread = await createThread(); const briefingMsg = messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, userId: 'default-user', catId: null, content: 'TOP SECRET BRIEFING', @@ -184,6 +193,7 @@ describe('POST /api/messages — replyTo validation', () => { const thread2 = await createThread('Thread 2'); const otherThreadMsg = messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'default-user', catId: null, content: 'message in thread 2', @@ -213,6 +223,7 @@ describe('POST /api/messages — replyTo validation', () => { const thread = await createThread(); const deleted = messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'default-user', catId: null, content: 'will be deleted', @@ -243,6 +254,7 @@ describe('POST /api/messages — replyTo validation', () => { const thread = await createThread(); const queued = messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'default-user', catId: null, content: 'queued message', @@ -273,6 +285,7 @@ describe('POST /api/messages — replyTo validation', () => { const thread = await createThread(); const canceled = messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'default-user', catId: null, content: 'canceled message', @@ -304,6 +317,7 @@ describe('POST /api/messages — replyTo validation', () => { const thread = await createThread(); const target = messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'default-user', catId: null, content: 'original message', @@ -335,6 +349,7 @@ describe('POST /api/messages — replyTo validation', () => { const thread = await createThread(); const whisperMsg = messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'default-user', catId: 'opus', content: 'secret whisper content', @@ -367,6 +382,7 @@ describe('POST /api/messages — replyTo validation', () => { // Parent whispered only to codex const whisperMsg = messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'default-user', catId: 'opus', content: 'private to codex only', @@ -401,6 +417,7 @@ describe('POST /api/messages — replyTo validation', () => { const thread = await createThread(); const whisperMsg = messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'default-user', catId: 'opus', content: 'whisper to codex', diff --git a/packages/api/test/report-harness-signal.test.js b/packages/api/test/report-harness-signal.test.js new file mode 100644 index 0000000000..51506931d5 --- /dev/null +++ b/packages/api/test/report-harness-signal.test.js @@ -0,0 +1,287 @@ +/** + * F257 V1 — cat_cafe_report_harness_signal handler tests. + * + * Semantics single source of truth: T-C (§3.6) — sourceAnchor union / 三条服务端 + * 校验 / recordedBy principal 注入 / incidentKey / 幂等;§4.5-2 await-append。 + * messageStore dep 用 fake getById(handler 契约只消费 getById);ledger 用真 + * Redis store(无 Redis → skip,与 store 测试同模式)。 + */ + +import assert from 'node:assert/strict'; +import { after, before, beforeEach, describe, it } from 'node:test'; +import { + assertRedisIsolationOrThrow, + cleanupPrefixedRedisKeys, + redisIsolationSkipReason, +} from './helpers/redis-test-helpers.js'; + +const REDIS_URL = process.env.REDIS_URL; +const OWNER = 'owner-f257-rhs'; +const RECORDER = 'cat-recorder'; + +const { handleReportHarnessSignal } = await import( + '../dist/infrastructure/harness-eval/deviation/report-harness-signal.js' +); + +/** Fake message fixtures keyed by id — handler only consumes getById (T-C ①②③素材). */ +function fixtureMessages() { + return new Map( + Object.entries({ + 'msg-user': { + id: 'msg-user', + threadId: 'th-1', + userId: OWNER, + catId: null, + content: 'x', + mentions: [], + timestamp: 1, + provenance: { author: 'user', routed: false, observation: 'original' }, + }, + 'msg-cat': { + id: 'msg-cat', + threadId: 'th-1', + userId: OWNER, + catId: 'opus', + content: 'x', + mentions: [], + timestamp: 2, + provenance: { author: 'cat', routed: false, observation: 'original' }, + }, + 'msg-foreign': { + id: 'msg-foreign', + threadId: 'th-9', + userId: 'owner-other', + catId: null, + content: 'x', + mentions: [], + timestamp: 3, + provenance: { author: 'user', routed: false, observation: 'original' }, + }, + 'msg-connector': { + id: 'msg-connector', + threadId: 'th-1', + userId: OWNER, + catId: null, + source: { connector: 'telegram', label: 'Telegram', icon: 'telegram' }, + content: 'x', + mentions: [], + timestamp: 4, + provenance: { author: 'external_user', routed: false, observation: 'original' }, + }, + 'msg-system': { + id: 'msg-system', + threadId: 'th-1', + userId: OWNER, + catId: null, + content: 'x', + mentions: [], + timestamp: 5, + provenance: { author: 'system', routed: false, observation: 'original' }, + }, + 'msg-derived-user': { + id: 'msg-derived-user', + threadId: 'th-1', + userId: OWNER, + catId: null, + content: 'x', + mentions: [], + timestamp: 6, + provenance: { + author: 'user', + routed: false, + observation: 'derived', + sourceRef: 'message:msg-user', + }, + }, + 'msg-tombstone': { + id: 'msg-tombstone', + threadId: 'th-1', + userId: OWNER, + catId: null, + content: '', + mentions: [], + timestamp: 7, + _tombstone: true, + provenance: { author: 'user', routed: false, observation: 'original' }, + }, + }), + ); +} + +function body(overrides = {}) { + return { + sourceAnchor: { kind: 'thread_message', messageId: 'msg-user' }, + subjectCatId: 'cat-subject', + source: 'peer', + note: 'observed deviation', + attributions: [ + { objectiveId: 'obj-routing-delivery', unitRefs: [{ unitType: 'segment', unitId: 'S1' }], weight: 0.8 }, + ], + ...overrides, + }; +} + +describe('F257 V1: handleReportHarnessSignal (T-C 契约)', { skip: redisIsolationSkipReason(REDIS_URL) }, () => { + let deviationLog; + let redis; + let connected = false; + let messages; + const principal = { userId: OWNER, catId: RECORDER }; + + async function call(b, p = principal) { + return handleReportHarnessSignal( + { messageStore: { getById: (id) => messages.get(id) ?? null }, deviationLog }, + p, + b, + ); + } + + before(async () => { + assertRedisIsolationOrThrow(REDIS_URL, 'handleReportHarnessSignal'); + const mod = await import('../dist/infrastructure/harness-eval/deviation/DeviationEventLog.js'); + const redisModule = await import('@cat-cafe/shared/utils'); + redis = redisModule.createRedisClient({ url: REDIS_URL }); + try { + await redis.ping(); + connected = true; + } catch { + await redis.quit().catch(() => {}); + return; + } + deviationLog = new mod.RedisDeviationEventLog(redis); + }); + + after(async () => { + if (redis && connected) { + await cleanupPrefixedRedisKeys(redis, [`deviation:*:${OWNER}`]); + await redis.quit(); + } + }); + + beforeEach(async (t) => { + if (!connected) return t.skip('Redis not connected'); + // owner-scoped cleanup —— 与 deviation-event-log.test.js 并发跑互不干扰 + await cleanupPrefixedRedisKeys(redis, [`deviation:*:${OWNER}`]); + messages = fixtureMessages(); + }); + + it('happy path: peer observation on same-owner message → appended, principal 注入', async () => { + const res = await call(body()); + assert.equal(res.status, 200, JSON.stringify(res.body)); + assert.equal(res.body.outcome, 'appended'); + assert.ok(res.body.eventId); + assert.ok(res.body.incidentKey); + + const q = await deviationLog.query({ ownerUserId: OWNER }); + assert.equal(q.events.length, 1); + const evt = q.events[0]; + assert.equal(evt.kind, 'manual_observation'); + assert.equal(evt.recordedBy, RECORDER, 'recordedBy = principal.catId(不可自报)'); + assert.equal(evt.ownerUserId, OWNER, 'ownerUserId = principal.userId(server-trusted)'); + assert.equal(evt.subjectCatId, 'cat-subject'); + assert.equal(evt.source, 'peer'); + assert.deepEqual(evt.anchors, { threadId: 'th-1', messageId: 'msg-user' }); + assert.deepEqual(evt.sourceAnchor, { kind: 'thread_message', messageId: 'msg-user' }); + }); + + it('校验①: anchor 实体不存在 → 404(含 tombstone —— 内容已 wipe 不可作证据锚)', async () => { + const missing = await call(body({ sourceAnchor: { kind: 'thread_message', messageId: 'msg-nope' } })); + assert.equal(missing.status, 404); + assert.equal(missing.body.error, 'anchor_not_found'); + + const tomb = await call(body({ sourceAnchor: { kind: 'thread_message', messageId: 'msg-tombstone' } })); + assert.equal(tomb.status, 404); + assert.equal(tomb.body.error, 'anchor_not_found'); + }); + + it('校验②: anchor 与 authenticated owner 不同域 → 403', async () => { + const res = await call(body({ sourceAnchor: { kind: 'thread_message', messageId: 'msg-foreign' } })); + assert.equal(res.status, 403); + assert.equal(res.body.error, 'anchor_owner_mismatch'); + assert.equal(await deviationLog.countInWindow(OWNER, 0, Date.now() + 1000), 0); + }); + + it('校验③: source=operator 时 anchor 作者必须为 operator', async () => { + const catAuthored = await call( + body({ source: 'operator', sourceAnchor: { kind: 'thread_message', messageId: 'msg-cat' } }), + ); + assert.equal(catAuthored.status, 403); + assert.equal(catAuthored.body.error, 'anchor_author_not_operator'); + + const connector = await call( + body({ source: 'operator', sourceAnchor: { kind: 'thread_message', messageId: 'msg-connector' } }), + ); + assert.equal(connector.status, 403, 'connector 消息 (catId=null, source present) 不是 operator 手笔'); + + const system = await call( + body({ source: 'operator', sourceAnchor: { kind: 'thread_message', messageId: 'msg-system' } }), + ); + assert.equal(system.status, 403, 'catId=null 不能把 provenance.author=system 冒充成 operator'); + + const derived = await call( + body({ source: 'operator', sourceAnchor: { kind: 'thread_message', messageId: 'msg-derived-user' } }), + ); + assert.equal(derived.status, 403, '派生的 user 上下文不是新的 operator assertion'); + + const ok = await call(body({ source: 'operator' })); + assert.equal(ok.status, 200); + assert.equal(ok.body.outcome, 'appended'); + }); + + it('source≠operator 时 cat-authored anchor 合法(③ 只约束 operator source)', async () => { + const res = await call(body({ sourceAnchor: { kind: 'thread_message', messageId: 'msg-cat' } })); + assert.equal(res.status, 200); + }); + + it('operator_confirmation anchor: V1 无 confirmation 存储 → 404(候选转正通道未落地)', async () => { + const res = await call(body({ sourceAnchor: { kind: 'operator_confirmation', confirmationId: 'conf-1' } })); + assert.equal(res.status, 404); + assert.equal(res.body.error, 'anchor_not_found'); + }); + + it('重复 incident → 200 outcome=incident_claimed + 原 eventId(显式去重不静默)', async () => { + const first = await call(body()); + assert.equal(first.body.outcome, 'appended'); + const dup = await call( + body({ + note: 'same incident, different wording', + attributions: [ + { objectiveId: 'obj-routing-delivery', unitRefs: [{ unitType: 'segment', unitId: 'S1' }], weight: 0.3 }, + ], + }), + ); + assert.equal(dup.status, 200); + assert.equal(dup.body.outcome, 'incident_claimed'); + assert.equal(dup.body.eventId, first.body.eventId); + assert.equal(await deviationLog.countInWindow(OWNER, 0, Date.now() + 1000), 1); + }); + + it('idempotencyKey 网络重试 → idempotent_replay 同 eventId(principal+thread scoped)', async () => { + const first = await call(body({ idempotencyKey: 'retry-42' })); + assert.equal(first.body.outcome, 'appended'); + const retry = await call(body({ idempotencyKey: 'retry-42' })); + assert.equal(retry.body.outcome, 'idempotent_replay'); + assert.equal(retry.body.eventId, first.body.eventId); + + // 不同 principal 的同名 idempotencyKey 不共享(scope 隔离)——但同 incident 仍被 claim 挡住 + const otherCat = await call(body({ idempotencyKey: 'retry-42' }), { userId: OWNER, catId: 'cat-other' }); + assert.equal(otherCat.body.outcome, 'incident_claimed'); + }); + + it('body 校验失败 → 400 invalid_body(weight 越界 / 未知字段即 spoof 尝试 / anchor 形状错)', async () => { + const badWeight = await call( + body({ attributions: [{ objectiveId: 'o', unitRefs: [{ unitType: 'segment', unitId: 'S1' }], weight: 0 }] }), + ); + assert.equal(badWeight.status, 400); + assert.equal(badWeight.body.error, 'invalid_body'); + + const spoof = await call(body({ recordedBy: 'cat-imposter' })); + assert.equal(spoof.status, 400, 'recordedBy 不是输入字段——出现即拒(T-C 不可自报)'); + + const badAnchor = await call(body({ sourceAnchor: { kind: 'thread_message' } })); + assert.equal(badAnchor.status, 400); + + const badSource = await call(body({ source: 'llm' })); + assert.equal(badSource.status, 400); + }); +}); diff --git a/packages/api/test/rich-block-interactive.test.js b/packages/api/test/rich-block-interactive.test.js index 8cd94fc8f0..da9f375386 100644 --- a/packages/api/test/rich-block-interactive.test.js +++ b/packages/api/test/rich-block-interactive.test.js @@ -117,6 +117,7 @@ describe('F096: MessageStore.updateExtra', () => { it('T11: updates extra.rich block state', () => { const store = new MessageStore(); const msg = store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'u1', catId: 'opus', content: 'hello', @@ -163,6 +164,7 @@ describe('F096: MessageStore.updateExtra', () => { it('T13: preserves other extra fields (regression)', () => { const store = new MessageStore(); const msg = store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'u1', catId: 'opus', content: 'hi', @@ -237,6 +239,7 @@ describe('F096: PATCH /block-state route guards (P1-1, P2-2)', () => { it('T19: returns 403 for wrong userId', async () => { const store = new MessageStore(); const msg = store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'owner', catId: 'opus', content: 'pick', @@ -263,6 +266,7 @@ describe('F096: PATCH /block-state route guards (P1-1, P2-2)', () => { it('T20: returns 400 for non-interactive block', async () => { const store = new MessageStore(); const msg = store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'owner', catId: 'opus', content: 'card msg', @@ -282,6 +286,7 @@ describe('F096: PATCH /block-state route guards (P1-1, P2-2)', () => { it('T21: returns 200 and calls updateExtra for valid owner + interactive block', async () => { const store = new MessageStore(); const msg = store.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'owner', catId: 'opus', content: 'pick', diff --git a/packages/api/test/route-serial-replyto-stream.test.js b/packages/api/test/route-serial-replyto-stream.test.js index d5c5953b8c..0f6b0b7448 100644 --- a/packages/api/test/route-serial-replyto-stream.test.js +++ b/packages/api/test/route-serial-replyto-stream.test.js @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; +import { after, before, describe, it } from 'node:test'; +import { catRegistry } from '@cat-cafe/shared'; function createMockService(catId, text) { return { @@ -59,7 +60,50 @@ function createMockDeps(services, appendCalls, initialMessages = []) { }; } +/** + * Filter helpers — separate business stream messages from guard notices + * emitted by the ack-liveness detection system (LI-005). + */ +function streamMsgs(appendCalls) { + return appendCalls.filter((m) => m.source?.connector !== 'ack-liveness-hint'); +} + +function guardNotices(appendCalls) { + return appendCalls.filter((m) => m.source?.connector === 'ack-liveness-hint'); +} + describe('routeSerial replyTo on stream messages', () => { + /** Save / restore catRegistry so mention detection resolves @缅因猫 → codex. */ + let savedConfigs; + before(() => { + savedConfigs = catRegistry.getAllConfigs(); + const minCat = (id, displayName, mentionPatterns, clientId, defaultModel) => ({ + id, + name: id, + displayName, + avatar: '', + color: { primary: '#000', secondary: '#fff' }, + mentionPatterns, + clientId, + defaultModel, + mcpSupport: true, + roleDescription: 'test', + personality: 'test', + }); + if (!catRegistry.has('opus')) { + catRegistry.register('opus', minCat('opus', '布偶猫', ['@布偶猫'], 'anthropic', 'claude-opus-4-6')); + } + if (!catRegistry.has('codex')) { + catRegistry.register('codex', minCat('codex', '缅因猫', ['@缅因猫'], 'openai', 'gpt-5.3-codex')); + } + }); + after(() => { + catRegistry.reset(); + for (const [id, config] of Object.entries(savedConfigs)) { + catRegistry.register(id, config); + } + }); + it('attaches replyTo + replyPreview to CLI A2A stream responses', async () => { const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); const appendCalls = []; @@ -76,9 +120,14 @@ describe('routeSerial replyTo on stream messages', () => { yielded.push(msg); } - assert.equal(appendCalls.length, 2, 'should persist both opus and codex stream messages'); - assert.equal(appendCalls[0].replyTo, undefined, 'originating cat should not reply to anything'); - assert.equal(appendCalls[1].replyTo, 'msg-1', 'A2A stream reply should persist replyTo to trigger message'); + // LI-005: ack-liveness hint fires for codex (A2A invocation with no routing exit / durable trigger). + // Filter business messages from guard notices to validate each category independently. + const msgs = streamMsgs(appendCalls); + const hints = guardNotices(appendCalls); + assert.equal(msgs.length, 2, 'should persist both opus and codex stream messages'); + assert.equal(hints.length, 1, 'A2A ack-liveness hint should fire for codex (no routing exit)'); + assert.equal(msgs[0].replyTo, undefined, 'originating cat should not reply to anything'); + assert.equal(msgs[1].replyTo, 'msg-1', 'A2A stream reply should persist replyTo to trigger message'); const codexText = yielded.find((msg) => msg.type === 'text' && msg.catId === 'codex'); assert.ok(codexText, 'should yield codex stream text'); @@ -117,8 +166,12 @@ describe('routeSerial replyTo on stream messages', () => { yielded.push(msg); } - assert.equal(appendCalls.length, 1, 'should persist queue-dispatched codex stream message'); - assert.equal(appendCalls[0].replyTo, 'msg-trigger', 'queue-dispatched A2A stream should persist trigger replyTo'); + // LI-005: ack-liveness hint fires for codex (queue-dispatched A2A, no routing exit / durable trigger). + const msgs = streamMsgs(appendCalls); + const hints = guardNotices(appendCalls); + assert.equal(msgs.length, 1, 'should persist queue-dispatched codex stream message'); + assert.equal(hints.length, 1, 'A2A ack-liveness hint should fire (no routing exit)'); + assert.equal(msgs[0].replyTo, 'msg-trigger', 'queue-dispatched A2A stream should persist trigger replyTo'); const codexText = yielded.find((msg) => msg.type === 'text' && msg.catId === 'codex'); assert.ok(codexText, 'should yield codex stream text'); diff --git a/packages/api/test/route-serial-routing-guard-remedial.test.js b/packages/api/test/route-serial-routing-guard-remedial.test.js index 3760217b1b..12e8e85dab 100644 --- a/packages/api/test/route-serial-routing-guard-remedial.test.js +++ b/packages/api/test/route-serial-routing-guard-remedial.test.js @@ -156,7 +156,7 @@ async function loadRealRoster() { async function runRoute(service, threadId, extraServices = {}, mockOptions = {}) { return withCatRegistryLock(async () => { - const { thinkingMode = 'play', ...depsOptions } = mockOptions; + const { thinkingMode = 'play', routeOptions = {}, ...depsOptions } = mockOptions; const original = catRegistry.getAllConfigs(); await loadRealRoster(); const appended = []; @@ -167,6 +167,7 @@ async function runRoute(service, threadId, extraServices = {}, mockOptions = {}) const yielded = []; for await (const msg of routeSerial(deps, ['codex'], 'guard test', 'user1', threadId, { thinkingMode, + ...routeOptions, })) { yielded.push(msg); } @@ -964,3 +965,207 @@ describe('F177 Phase H — route-serial routing guard remedial invoke', () => { assert.match(failure.content, /补救失败|没有合法的路由出口/); }); }); + +describe('F257 LI-001 — route-serial action liveness guard', () => { + const completionRouteOptions = { completionRequirement: 'action-or-routing-exit' }; + + test('text-only acknowledgement gets one remedial invoke and a non-routing tool action satisfies it', async () => { + const service = createSequenceService( + 'codex', + [ + 'Acknowledged. I will continue.', + [ + { type: 'tool_use', toolName: 'cat_cafe_search_evidence', toolInput: { q: 'F257 status' } }, + { type: 'tool_result', content: '{"status":"ok"}' }, + { type: 'text', content: 'I checked the current state.' }, + ], + ], + { needsGuard: false }, + ); + + const { appended, calls } = await runRoute( + service, + 'thread-action-liveness-tool', + {}, + { + routeOptions: completionRouteOptions, + }, + ); + + assert.equal(calls.length, 2, 'action liveness must spend exactly one remedial invoke'); + assert.match(calls[1], /动作活性守卫/); + assert.equal( + appended.find((m) => m.source?.connector === 'action-liveness-guard-failure'), + undefined, + 'a remedial tool action satisfies the completion contract', + ); + }); + + test('two empty successful responses stop after one remedial and emit a visible failure notice', async () => { + const service = createSequenceService('codex', ['', ''], { needsGuard: false }); + + const { appended, calls } = await runRoute( + service, + 'thread-action-liveness-empty', + {}, + { + routeOptions: completionRouteOptions, + }, + ); + + assert.equal(calls.length, 2, 'guard must never recursively invoke a third time'); + const failure = appended.find((m) => m.source?.connector === 'action-liveness-guard-failure'); + assert.ok(failure, 'second violation must leave a durable visible failure notice'); + assert.match(failure.content, /补救失败/); + assert.match(failure.content, /动作|路由/); + }); + + test('completion guard and existing server routing guard share one remedial budget', async () => { + const service = createSequenceService('codex', ['Acknowledged.', '@co-creator'], { needsGuard: true }); + + const { calls } = await runRoute( + service, + 'thread-action-liveness-shared-budget', + {}, + { + routeOptions: completionRouteOptions, + }, + ); + + assert.equal(calls.length, 2, 'overlapping guards must not each launch their own remedial invoke'); + }); + + for (const [label, firstTurn] of [ + ['text response', 'Acknowledged.'], + ['empty response', ''], + ]) { + test(`ordinary tool remedial preserves the routing-guard failure for an initial ${label}`, async () => { + const service = createSequenceService( + 'codex', + [ + firstTurn, + [ + { type: 'tool_use', toolName: 'cat_cafe_search_evidence', toolInput: { q: 'F257 status' } }, + { type: 'tool_result', content: '{"status":"ok"}' }, + ], + ], + { needsGuard: true }, + ); + + const { appended, calls } = await runRoute( + service, + `thread-action-liveness-routing-intersection-${label.replace(' ', '-')}`, + {}, + { + routeOptions: completionRouteOptions, + }, + ); + + assert.equal(calls.length, 2, 'the two guards must share one remedial invoke'); + assert.ok( + appended.find((message) => message.source?.connector === 'routing-guard-failure'), + 'a non-routing tool satisfies action liveness but must not satisfy the stricter routing guard', + ); + assert.equal( + appended.find((message) => message.source?.connector === 'action-liveness-guard-failure'), + undefined, + 'the failure notice must identify the remaining routing contract, not action liveness', + ); + }); + } + + test('completion requirement applies only to the hold-ball wake target, not downstream A2A recipients', async () => { + const codexService = createSequenceService('codex', ['@opus'], { needsGuard: false }); + const opusService = createSequenceService('opus', ['Acknowledged by downstream cat.'], { needsGuard: false }); + + const { calls } = await runRoute( + codexService, + 'thread-action-liveness-a2a-scope', + { opus: opusService }, + { + routeOptions: completionRouteOptions, + }, + ); + + assert.equal(calls.length, 1, 'the woken target already satisfied the contract by routing'); + assert.equal(opusService.calls.length, 1, 'downstream A2A acknowledgement must not inherit the wake-only guard'); + }); + + test('provider error does not trigger an action-liveness remedial invoke', async () => { + const service = createSequenceService( + 'codex', + [ + [ + { type: 'text', content: 'partial response' }, + { type: 'error', error: 'provider failed' }, + ], + ], + { needsGuard: true }, + ); + + const { calls } = await runRoute( + service, + 'thread-action-liveness-provider-error', + {}, + { + routeOptions: completionRouteOptions, + }, + ); + + assert.equal(calls.length, 1, 'provider failures must propagate without an automatic retry'); + }); + + test('provider error during the bounded remedial is not mislabeled as an action-liveness failure', async () => { + const service = createSequenceService( + 'codex', + ['Acknowledged. I will continue.', [{ type: 'error', error: 'provider failed during remedial' }]], + { needsGuard: false }, + ); + + const { appended, calls, yielded } = await runRoute( + service, + 'thread-action-liveness-remedial-provider-error', + {}, + { + routeOptions: completionRouteOptions, + }, + ); + + assert.equal(calls.length, 2, 'the original contract violation may spend the single remedial invoke'); + assert.ok( + yielded.some((event) => event.type === 'error' && event.error === 'provider failed during remedial'), + 'the remedial provider failure must remain visible as the terminal error', + ); + assert.equal( + appended.find((message) => message.source?.connector === 'action-liveness-guard-failure'), + undefined, + 'a provider failure is not evidence that the cat violated the completion contract twice', + ); + }); + + test('abort during the first pass does not trigger either guard', async () => { + const controller = new AbortController(); + const calls = []; + const service = { + calls, + needsServerRoutingGuard: () => true, + async *invoke(prompt) { + calls.push(prompt); + controller.abort(); + yield { type: 'text', catId: 'codex', content: 'partial response', timestamp: Date.now() }; + yield { type: 'done', catId: 'codex', timestamp: Date.now() }; + }, + }; + + await runRoute( + service, + 'thread-action-liveness-abort', + {}, + { + routeOptions: { ...completionRouteOptions, signal: controller.signal }, + }, + ); + + assert.equal(calls.length, 1, 'abort must terminate without a remedial invoke'); + }); +}); diff --git a/packages/api/test/routing-decision.test.js b/packages/api/test/routing-decision.test.js index 50840f78e6..dbb11c6a42 100644 --- a/packages/api/test/routing-decision.test.js +++ b/packages/api/test/routing-decision.test.js @@ -99,7 +99,7 @@ describe('resolveRoutingDecisions — inline_mention', () => { { type: 'inline_mention', cats: ['codex'], content: 'hi', callerCatId: 'opus' }, ctx({ peekStreak: () => ({ wouldBlock: true, count: 4 }) }), ); - assert.deepEqual(out, [{ action: 'block_pingpong', cat: 'codex', pairCount: 4 }]); + assert.deepEqual(out, [{ action: 'block_pingpong', cat: 'codex', pairCount: 4, reason: 'pingpong_streak' }]); }); test('multi-cat: per-target decisions in order', async () => { diff --git a/packages/api/test/routing-guard-remedial.test.js b/packages/api/test/routing-guard-remedial.test.js index e2551e410a..d73456ccb5 100644 --- a/packages/api/test/routing-guard-remedial.test.js +++ b/packages/api/test/routing-guard-remedial.test.js @@ -8,8 +8,11 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import { + buildActionLivenessRemedialPrompt, buildRemedialPrompt, + hasActionOrRoutingExit, hasValidRoutingExit, + shouldRemediateActionLiveness, shouldRemediateRouting, } from '../dist/domains/cats/services/agents/routing/guards/routing-guard-remedial.js'; @@ -109,3 +112,44 @@ describe('F177 Phase H — buildRemedialPrompt', () => { assert.match(p, /无回调/); }); }); + +describe('F257 LI-001 — action liveness completion guard', () => { + const completionBase = { + ...base, + completionRequirement: 'action-or-routing-exit', + attempted: false, + hadError: false, + aborted: false, + }; + + test('empty or text-only successful completion requires one remedial invoke', () => { + assert.equal(shouldRemediateActionLiveness(completionBase), true); + }); + + test('any real tool call satisfies the action side even when it is not a routing tool', () => { + assert.equal(hasActionOrRoutingExit({ ...base, toolNames: ['cat_cafe_search_evidence'] }), true); + assert.equal(shouldRemediateActionLiveness({ ...completionBase, toolNames: ['cat_cafe_search_evidence'] }), false); + }); + + test('each existing mechanical routing exit satisfies the routing side', () => { + assert.equal(hasActionOrRoutingExit({ ...base, lineStartMentions: ['opus'] }), true); + assert.equal(hasActionOrRoutingExit({ ...base, toolNames: ['cat_cafe_hold_ball'] }), true); + assert.equal(hasActionOrRoutingExit({ ...base, structuredTargetCats: ['opus'] }), true); + assert.equal(hasActionOrRoutingExit({ ...base, hasCoCreatorLineStartMention: true }), true); + }); + + test('ordinary invocations, provider errors, aborts, and spent budget never trigger this guard', () => { + assert.equal(shouldRemediateActionLiveness({ ...completionBase, completionRequirement: undefined }), false); + assert.equal(shouldRemediateActionLiveness({ ...completionBase, hadError: true }), false); + assert.equal(shouldRemediateActionLiveness({ ...completionBase, aborted: true }), false); + assert.equal(shouldRemediateActionLiveness({ ...completionBase, attempted: true }), false); + }); + + test('remedial prompt requires a concrete action or explicit route and rejects text-only acknowledgement', () => { + const prompt = buildActionLivenessRemedialPrompt(); + assert.match(prompt, /动作活性守卫/); + assert.match(prompt, /工具/); + assert.match(prompt, /行首/); + assert.match(prompt, /纯文本|只回复文字/); + }); +}); diff --git a/packages/api/test/s1-review-fixes.test.js b/packages/api/test/s1-review-fixes.test.js index ea5c348d92..ea3a1179d4 100644 --- a/packages/api/test/s1-review-fixes.test.js +++ b/packages/api/test/s1-review-fixes.test.js @@ -307,6 +307,13 @@ describe('R2: delete-guard race via POST /api/messages route', () => { const mockRouter = { async resolveTargetsAndIntent(_msg) { return { + attemptBatch: { + parserMode: 'user', + spanBasis: 'lowercased_message', + attempts: [], + truncated: false, + metricEligible: true, + }, targetCats: ['opus'], intent: { intent: 'execute', explicit: false, promptTags: [] }, }; diff --git a/packages/api/test/scheduler-delivery.test.js b/packages/api/test/scheduler-delivery.test.js index 1cd89701fc..8680cb7dd2 100644 --- a/packages/api/test/scheduler-delivery.test.js +++ b/packages/api/test/scheduler-delivery.test.js @@ -1,11 +1,19 @@ import assert from 'node:assert/strict'; import { describe, it, mock } from 'node:test'; +import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; import { createDeliverFn, createLifecycleToastFn } from '../dist/infrastructure/scheduler/delivery.js'; +/** + * sol P1 regression (2026-07-20 → 23 incident): the scheduler delivery writer + * silently missed the F257 V1 write-boundary contract (`append requires + * provenance`), and the AnyFn-typed mock in this file self-certified — every + * scheduled delivery failed at runtime while tests stayed green. These tests + * therefore run against the REAL in-memory MessageStore, which enforces + * assertProvenanceConsistent on every append. + */ describe('createDeliverFn', () => { - it('appends connector message to store and broadcasts connector_message via socket', async () => { - const appendResult = { id: 'msg-1', threadId: 'th-1', timestamp: 1234567890 }; - const messageStore = { append: mock.fn(() => appendResult) }; + it('appends connector message to a REAL store with system provenance and broadcasts', async () => { + const messageStore = new MessageStore(); const socketManager = { broadcastToRoom: mock.fn(), emitToUser: mock.fn() }; const deliver = createDeliverFn({ messageStore, socketManager }); @@ -16,16 +24,16 @@ describe('createDeliverFn', () => { extra: { scheduler: { hiddenTrigger: true } }, }); - assert.equal(msgId, 'msg-1'); - assert.equal(messageStore.append.mock.calls.length, 1); - const appendArg = messageStore.append.mock.calls[0].arguments[0]; - assert.equal(appendArg.threadId, 'th-1'); - assert.equal(appendArg.content, 'Hello reminder'); - assert.equal(appendArg.catId, null); - assert.equal(appendArg.origin, 'callback'); - assert.equal(appendArg.source.connector, 'scheduler'); - assert.equal(appendArg.source.label, '定时任务'); - assert.equal(appendArg.extra.scheduler.hiddenTrigger, true); + const stored = messageStore.getById(msgId); + assert.ok(stored, 'message persisted in real store'); + assert.deepEqual(stored.provenance, { author: 'system', routed: false, observation: 'original' }); + assert.equal(stored.threadId, 'th-1'); + assert.equal(stored.content, 'Hello reminder'); + assert.equal(stored.catId, null); + assert.equal(stored.origin, 'callback'); + assert.equal(stored.source.connector, 'scheduler'); + assert.equal(stored.source.label, '定时任务'); + assert.equal(stored.extra.scheduler.hiddenTrigger, true); assert.equal(socketManager.broadcastToRoom.mock.calls.length, 1); const [room, event, payload] = socketManager.broadcastToRoom.mock.calls[0].arguments; assert.equal(room, 'thread:th-1'); @@ -36,21 +44,48 @@ describe('createDeliverFn', () => { assert.equal(payload.message.extra.scheduler.hiddenTrigger, true); }); - it('returns message id from store', async () => { - const messageStore = { append: mock.fn(() => ({ id: 'msg-42' })) }; + it('REGRESSION: real store rejects an append without provenance (the incident failure mode)', () => { + const messageStore = new MessageStore(); + assert.throws( + () => + messageStore.append({ + userId: 'user-1', + catId: null, + content: 'no provenance', + mentions: [], + origin: 'callback', + timestamp: Date.now(), + threadId: 'th-x', + }), + /append requires provenance/, + ); + }); + + it('idempotencyKey makes a retried delivery return the original message (once-task retry safety)', async () => { + const messageStore = new MessageStore(); const socketManager = { broadcastToRoom: mock.fn(), emitToUser: mock.fn() }; const deliver = createDeliverFn({ messageStore, socketManager }); - const msgId = await deliver({ - threadId: 'th-2', - content: 'test', - userId: 'u-1', + const first = await deliver({ + threadId: 'th-1', + content: 'wake', + userId: 'scheduler', + idempotencyKey: 'reminder:hold-ball-1', + }); + const second = await deliver({ + threadId: 'th-1', + content: 'wake', + userId: 'scheduler', + idempotencyKey: 'reminder:hold-ball-1', }); - assert.equal(msgId, 'msg-42'); + + assert.equal(second, first); + assert.equal(messageStore.getByThread('th-1').length, 1); }); it('works with async messageStore.append', async () => { - const messageStore = { append: mock.fn(async () => ({ id: 'msg-async' })) }; + const inner = new MessageStore(); + const messageStore = { append: async (msg) => inner.append(msg) }; const socketManager = { broadcastToRoom: mock.fn(), emitToUser: mock.fn() }; const deliver = createDeliverFn({ messageStore, socketManager }); @@ -59,7 +94,9 @@ describe('createDeliverFn', () => { content: 'async test', userId: 'u-1', }); - assert.equal(msgId, 'msg-async'); + const stored = await inner.getById(msgId); + assert.ok(stored); + assert.deepEqual(stored.provenance, { author: 'system', routed: false, observation: 'original' }); }); }); diff --git a/packages/api/test/scheduler-reply-userid-backfill.test.js b/packages/api/test/scheduler-reply-userid-backfill.test.js index 9a414dc015..98997fbe13 100644 --- a/packages/api/test/scheduler-reply-userid-backfill.test.js +++ b/packages/api/test/scheduler-reply-userid-backfill.test.js @@ -67,6 +67,7 @@ describe('scheduler reply userid backfill', { skip: redisIsolationSkipReason(RED const now = Date.now(); const triggerMessage = await messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'scheduler', catId: 'system', content: '[定时任务] 发今天的 AI 新闻', @@ -95,6 +96,7 @@ describe('scheduler reply userid backfill', { skip: redisIsolationSkipReason(RED assert.ok(completed, 'invocation should persist trigger message id'); const hiddenReply = await messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'scheduler', catId: 'opus', content: '这是旧的猫回复', @@ -132,6 +134,7 @@ describe('scheduler reply userid backfill', { skip: redisIsolationSkipReason(RED const now = Date.now(); const triggerMessage = await messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'scheduler', catId: 'system', content: '[定时任务] eval:a2a daily run', @@ -142,6 +145,7 @@ describe('scheduler reply userid backfill', { skip: redisIsolationSkipReason(RED }); const hiddenStreamReply = await messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'scheduler', catId: 'codex', content: 'eval:a2a daily eval result from route-serial stream', @@ -186,6 +190,7 @@ describe('scheduler reply userid backfill', { skip: redisIsolationSkipReason(RED const now = Date.now(); const triggerMessage = await messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'scheduler', catId: 'system', content: '[定时任务] eval:a2a daily run', @@ -196,6 +201,7 @@ describe('scheduler reply userid backfill', { skip: redisIsolationSkipReason(RED }); const hiddenStreamReply = await messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'scheduler', catId: 'codex', content: 'eval:a2a result persisted under scheduler scope', diff --git a/packages/api/test/scheduler/dynamic-task-store.test.js b/packages/api/test/scheduler/dynamic-task-store.test.js index 2230bbc7fe..c55b6a42f3 100644 --- a/packages/api/test/scheduler/dynamic-task-store.test.js +++ b/packages/api/test/scheduler/dynamic-task-store.test.js @@ -37,6 +37,7 @@ test('dynamic_task_defs has correct columns', () => { assert.ok(names.includes('enabled')); assert.ok(names.includes('created_by')); assert.ok(names.includes('created_at')); + assert.ok(names.includes('retry_attempts'), 'V27: retry_attempts column should exist'); db.close(); }); @@ -123,4 +124,21 @@ describe('DynamicTaskStore', () => { assert.equal(loaded.trigger.type, 'once'); assert.equal(loaded.trigger.fireAt, fireAt); }); + + test('V27: retryAttempts defaults to 0 on insert', () => { + store.insert(SAMPLE_DEF); + const loaded = store.getById('dyn-001'); + assert.equal(loaded.retryAttempts, 0); + }); + + test('V27: updateRetryState atomically updates trigger + retryAttempts', () => { + const fireAt = Date.now() + 120_000; + store.insert({ ...SAMPLE_DEF, trigger: { type: 'once', fireAt } }); + const newFireAt = fireAt + 30_000; + const ok = store.updateRetryState('dyn-001', { type: 'once', fireAt: newFireAt }, 2); + assert.ok(ok); + const loaded = store.getById('dyn-001'); + assert.equal(loaded.trigger.fireAt, newFireAt); + assert.equal(loaded.retryAttempts, 2); + }); }); diff --git a/packages/api/test/scheduler/phase4-e2e.test.js b/packages/api/test/scheduler/phase4-e2e.test.js index d3f3be647b..188789943b 100644 --- a/packages/api/test/scheduler/phase4-e2e.test.js +++ b/packages/api/test/scheduler/phase4-e2e.test.js @@ -6,6 +6,7 @@ import assert from 'node:assert/strict'; import { beforeEach, describe, mock, test } from 'node:test'; import Database from 'better-sqlite3'; +import { assertProvenanceConsistent } from '../../dist/domains/cats/services/stores/ports/MessageStore.js'; import { applyMigrations } from '../../dist/domains/memory/schema.js'; import { DynamicTaskStore } from '../../dist/infrastructure/scheduler/DynamicTaskStore.js'; import { createDeliverFn } from '../../dist/infrastructure/scheduler/delivery.js'; @@ -31,9 +32,15 @@ describe('F139 Phase 4 E2E', () => { store = new DynamicTaskStore(db); deliverCalls = []; - // Mock messageStore + socketManager for delivery + // Mock messageStore + socketManager for delivery. The mock MUST execute + // the real write-boundary contract (sol P1 regression 2026-07-23: a + // provenance-blind mock self-certified while every runtime delivery + // failed with `append requires provenance`). const mockMessageStore = { - append: mock.fn((msg) => ({ id: `msg-${Date.now()}`, threadId: msg.threadId })), + append: mock.fn((msg) => { + assertProvenanceConsistent(msg); + return { id: `msg-${Date.now()}`, threadId: msg.threadId }; + }), }; const mockSocketManager = { broadcastToRoom: mock.fn(), diff --git a/packages/api/test/scheduler/task-runner-v2.test.js b/packages/api/test/scheduler/task-runner-v2.test.js index aa3efae324..b6263587ca 100644 --- a/packages/api/test/scheduler/task-runner-v2.test.js +++ b/packages/api/test/scheduler/task-runner-v2.test.js @@ -150,8 +150,8 @@ describe('TaskRunnerV2', () => { const rows = ledger.query('partial-fail', 10); assert.equal(rows.length, 2); const bySubject = Object.fromEntries(rows.map((r) => [r.subject_key, r.outcome])); - assert.equal(bySubject['a'], 'RUN_DELIVERED'); - assert.equal(bySubject['b'], 'RUN_FAILED'); + assert.equal(bySubject.a, 'RUN_DELIVERED'); + assert.equal(bySubject.b, 'RUN_FAILED'); }); it('disabled task → no execute, no ledger', async () => { @@ -1267,3 +1267,297 @@ describe('TaskRunnerV2 — once trigger (#415)', () => { runner.stop(); }); }); + +describe('TaskRunnerV2 — once task RUN_FAILED bounded retry (sol P1 regression收口 2026-07-23)', () => { + let db, ledger, dynamicTaskStore; + const noop = () => {}; + const silentLogger = { info: noop, error: noop }; + + beforeEach(async () => { + db = new Database(':memory:'); + const { applyMigrations } = await import('../../dist/domains/memory/schema.js'); + const { RunLedger } = await import('../../dist/infrastructure/scheduler/RunLedger.js'); + const { DynamicTaskStore } = await import('../../dist/infrastructure/scheduler/DynamicTaskStore.js'); + applyMigrations(db); + ledger = new RunLedger(db); + dynamicTaskStore = new DynamicTaskStore(db); + }); + + const makeFailingOnceTask = (id, fireAt, execute) => ({ + id, + profile: 'awareness', + trigger: { type: 'once', fireAt }, + supportsOnceRetry: true, + admission: { + gate: async () => ({ run: true, workItems: [{ signal: 'go', subjectKey: `k-${id}` }] }), + }, + run: { overlap: 'skip', timeoutMs: 5000, execute }, + state: { runLedger: 'sqlite' }, + outcome: { whenNoSignal: 'drop' }, + enabled: () => true, + }); + + it('RUN_FAILED once task is retried (1 initial + 3 retries) before loud retire — never silently dropped', async () => { + const { TaskRunnerV2 } = await import('../../dist/infrastructure/scheduler/TaskRunnerV2.js'); + const runner = new TaskRunnerV2({ logger: silentLogger, ledger, dynamicTaskStore, onceRetryDelayMs: 20 }); + let calls = 0; + runner.registerDynamic( + makeFailingOnceTask('once-fail-always', Date.now() + 20, async () => { + calls += 1; + throw new Error( + 'append requires provenance: every writer must declare { author, routed, observation } explicitly', + ); + }), + 'dyn-fail-always', + ); + runner.start(); + await new Promise((r) => setTimeout(r, 700)); + + assert.equal(calls, 4, '1 initial fire + 3 bounded retries'); + const rows = ledger.query('once-fail-always', 10); + assert.equal(rows.filter((row) => row.outcome === 'RUN_FAILED').length, 4); + assert.ok( + !runner.getRegisteredTasks().includes('once-fail-always'), + 'task retires only after retries are exhausted', + ); + runner.stop(); + }); + + it('recovers on retry: transient failure then success → RUN_DELIVERED, clean retire', async () => { + const { TaskRunnerV2 } = await import('../../dist/infrastructure/scheduler/TaskRunnerV2.js'); + const runner = new TaskRunnerV2({ logger: silentLogger, ledger, dynamicTaskStore, onceRetryDelayMs: 20 }); + let calls = 0; + runner.registerDynamic( + makeFailingOnceTask('once-fail-transient', Date.now() + 20, async () => { + calls += 1; + if (calls === 1) throw new Error('transient store failure'); + }), + 'dyn-fail-transient', + ); + runner.start(); + await new Promise((r) => setTimeout(r, 500)); + + assert.equal(calls, 2, 'failed once, succeeded on first retry'); + const rows = ledger.query('once-fail-transient', 10); + assert.equal(rows[0].outcome, 'RUN_DELIVERED'); + assert.equal(rows[1].outcome, 'RUN_FAILED'); + assert.ok(!runner.getRegisteredTasks().includes('once-fail-transient')); + runner.stop(); + }); + + it('successful once task still retires immediately (no behavior change on the happy path)', async () => { + const { TaskRunnerV2 } = await import('../../dist/infrastructure/scheduler/TaskRunnerV2.js'); + const runner = new TaskRunnerV2({ logger: silentLogger, ledger, dynamicTaskStore, onceRetryDelayMs: 20 }); + let calls = 0; + runner.registerDynamic( + makeFailingOnceTask('once-happy', Date.now() + 20, async () => { + calls += 1; + }), + 'dyn-happy', + ); + runner.start(); + await new Promise((r) => setTimeout(r, 200)); + + assert.equal(calls, 1); + assert.ok(!runner.getRegisteredTasks().includes('once-happy')); + runner.stop(); + }); + + it('restart during RUN_FAILED backoff resumes retry countdown from persisted state', async () => { + const { TaskRunnerV2 } = await import('../../dist/infrastructure/scheduler/TaskRunnerV2.js'); + const { reminderTemplate } = await import('../../dist/infrastructure/scheduler/templates/reminder.js'); + + let calls = 0; + const defId = 'dyn-restart-resume'; + const fireAt = Date.now() + 50; + + dynamicTaskStore.insert({ + id: defId, + templateId: 'reminder', + trigger: { type: 'once', fireAt }, + params: { message: 'restart resume test', triggerUserId: 'user-1' }, + display: { label: 'restart test', category: 'system' }, + deliveryThreadId: 'thread-1', + enabled: true, + createdBy: 'test', + createdAt: new Date().toISOString(), + retryAttempts: 0, + }); + + const runner1 = new TaskRunnerV2({ + logger: silentLogger, + ledger, + dynamicTaskStore, + onceRetryDelayMs: 400, + deliver: async () => { + calls += 1; + throw new Error('delivery failed'); + }, + }); + runner1.hydrateDynamic(dynamicTaskStore, { + get: (id) => (id === 'reminder' ? reminderTemplate : null), + }); + runner1.start(); + + // Wait for initial fire (fireAt + small buffer) and enter backoff, but stop before retry fires. + await new Promise((r) => setTimeout(r, 150)); + assert.equal(calls, 1, 'initial fire happened before restart'); + + const persistedBeforeRestart = dynamicTaskStore.getById(defId); + assert.ok(persistedBeforeRestart, 'task still persisted after stop'); + assert.ok(persistedBeforeRestart.trigger.fireAt > Date.now(), 'retry fireAt persisted into the future'); + assert.equal(persistedBeforeRestart.retryAttempts, 1, 'retry attempt counter persisted'); + + runner1.stop(); + + // Simulate process restart: new runner, same DB. + const runner2 = new TaskRunnerV2({ + logger: silentLogger, + ledger, + dynamicTaskStore, + onceRetryDelayMs: 40, + deliver: async () => { + calls += 1; + throw new Error('delivery failed'); + }, + }); + const loaded = runner2.hydrateDynamic(dynamicTaskStore, { + get: (id) => (id === 'reminder' ? reminderTemplate : null), + }); + assert.equal(loaded, 1, 'task is rehydrated, not treated as missed window'); + runner2.start(); + + // Wait for the remaining retries to exhaust. + await new Promise((r) => setTimeout(r, 600)); + + // First runner fired once; second runner resumes with retryAttempts=1 and runs 3 more times. + assert.equal(calls, 4, '1 initial fire + 3 bounded retries across restart'); + const rows = ledger.query(defId, 10); + assert.equal(rows.filter((r) => r.outcome === 'RUN_FAILED').length, 4); + assert.ok(!runner2.getRegisteredTasks().includes(defId), 'task retires after retries exhausted'); + assert.equal(dynamicTaskStore.getById(defId), null, 'dynamic task removed after retirement'); + runner2.stop(); + }); + + it('restart after backoff deadline still resumes retry instead of SKIP_MISSED_WINDOW', async () => { + const { TaskRunnerV2 } = await import('../../dist/infrastructure/scheduler/TaskRunnerV2.js'); + const { reminderTemplate } = await import('../../dist/infrastructure/scheduler/templates/reminder.js'); + + let calls = 0; + const defId = 'dyn-restart-overdue'; + const fireAt = Date.now() + 50; + + dynamicTaskStore.insert({ + id: defId, + templateId: 'reminder', + trigger: { type: 'once', fireAt }, + params: { message: 'restart overdue test', triggerUserId: 'user-1' }, + display: { label: 'overdue test', category: 'system' }, + deliveryThreadId: 'thread-1', + enabled: true, + createdBy: 'test', + createdAt: new Date().toISOString(), + retryAttempts: 0, + }); + + const runner1 = new TaskRunnerV2({ + logger: silentLogger, + ledger, + dynamicTaskStore, + onceRetryDelayMs: 300, + deliver: async () => { + calls += 1; + throw new Error('delivery failed'); + }, + }); + runner1.hydrateDynamic(dynamicTaskStore, { + get: (id) => (id === 'reminder' ? reminderTemplate : null), + }); + runner1.start(); + + // Wait for initial fire, then stop before the retry backoff fires. + await new Promise((r) => setTimeout(r, 120)); + assert.equal(calls, 1, 'initial fire happened'); + runner1.stop(); + + // Wait until the persisted retry fireAt is in the past. + await new Promise((r) => setTimeout(r, 250)); + const persisted = dynamicTaskStore.getById(defId); + assert.ok(persisted, 'task still persisted'); + assert.ok(persisted.trigger.fireAt < Date.now(), 'retry fireAt is now in the past'); + assert.equal(persisted.retryAttempts, 1, 'retry attempt counter persisted'); + + // Restart: must NOT SKIP_MISSED_WINDOW; should fire immediately and continue retries. + const runner2 = new TaskRunnerV2({ + logger: silentLogger, + ledger, + dynamicTaskStore, + onceRetryDelayMs: 40, + deliver: async () => { + calls += 1; + throw new Error('delivery failed'); + }, + }); + const loaded = runner2.hydrateDynamic(dynamicTaskStore, { + get: (id) => (id === 'reminder' ? reminderTemplate : null), + }); + assert.equal(loaded, 1, 'overdue retry task is rehydrated, not treated as missed window'); + runner2.start(); + + // Wait for immediate fire + remaining retries to exhaust. + await new Promise((r) => setTimeout(r, 500)); + + assert.equal(calls, 4, '1 initial fire + 3 bounded retries across overdue restart'); + const rows = ledger.query(defId, 10); + assert.equal(rows.filter((r) => r.outcome === 'RUN_FAILED').length, 4); + assert.ok(!rows.some((r) => r.outcome === 'SKIP_MISSED_WINDOW'), 'no missed window record'); + assert.ok(!runner2.getRegisteredTasks().includes(defId), 'task retires after retries exhausted'); + runner2.stop(); + }); + + it('non-retry-safe once task is retired immediately after RUN_FAILED — no duplicate side-effects', async () => { + const { TaskRunnerV2 } = await import('../../dist/infrastructure/scheduler/TaskRunnerV2.js'); + + let appends = 0; + const runner = new TaskRunnerV2({ + logger: silentLogger, + ledger, + dynamicTaskStore, + onceRetryDelayMs: 20, + deliver: async () => { + appends += 1; + throw new Error('broadcast failed after append'); + }, + }); + + runner.registerDynamic( + { + id: 'once-no-retry', + profile: 'awareness', + trigger: { type: 'once', fireAt: Date.now() + 20 }, + admission: { + gate: async () => ({ run: true, workItems: [{ signal: 'go', subjectKey: 'k-no-retry' }] }), + }, + run: { + overlap: 'skip', + timeoutMs: 5000, + async execute(_signal, _subjectKey, ctx) { + await ctx.deliver({ threadId: 'thread-1', content: 'scheduled message', userId: 'user-1' }); + }, + }, + state: { runLedger: 'sqlite' }, + outcome: { whenNoSignal: 'drop' }, + enabled: () => true, + }, + 'dyn-no-retry', + ); + runner.start(); + await new Promise((r) => setTimeout(r, 200)); + + assert.equal(appends, 1, 'non-retry-safe task must not retry append'); + assert.ok(!runner.getRegisteredTasks().includes('once-no-retry'), 'task retired immediately'); + const rows = ledger.query('once-no-retry', 10); + assert.equal(rows.filter((r) => r.outcome === 'RUN_FAILED').length, 1); + runner.stop(); + }); +}); diff --git a/packages/api/test/segment-judgment-cache.test.js b/packages/api/test/segment-judgment-cache.test.js new file mode 100644 index 0000000000..90c9c94cfa --- /dev/null +++ b/packages/api/test/segment-judgment-cache.test.js @@ -0,0 +1,499 @@ +/** + * F257 Phase D — SegmentJudgmentCache unit tests. + * + * Red tests for review findings: + * P1-2: Cache drops segmentVersion from SegmentJudgment + * P2-3: No direct tests existed + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +// ── FakeRedis with HASH + pipeline support ────────────────── + +class FakeRedis { + constructor() { + this.hashes = new Map(); // key → Map + this.zsets = new Map(); // key → [{score, member}] + } + + async hset(key, field, value) { + const h = this.hashes.get(key) ?? new Map(); + h.set(field, value); + this.hashes.set(key, h); + return 1; + } + + async hget(key, field) { + return this.hashes.get(key)?.get(field) ?? null; + } + + async zadd(key, score, member) { + const z = this.zsets.get(key) ?? []; + z.push({ score, member }); + z.sort((a, b) => a.score - b.score || a.member.localeCompare(b.member)); + this.zsets.set(key, z); + return 1; + } + + async zrangebyscore(key, min, max, ...args) { + const z = this.zsets.get(key) ?? []; + const minN = min === '-inf' ? -Infinity : Number(min); + const maxN = max === '+inf' ? Infinity : Number(max); + let filtered = z.filter((e) => e.score >= minN && e.score <= maxN); + if (args[0] === 'LIMIT') { + const offset = Number(args[1]); + const count = Number(args[2]); + filtered = filtered.slice(offset, offset + count); + } + return filtered.map((e) => e.member); + } + + pipeline() { + const ops = []; + const self = this; + const pipe = { + hset(key, field, value) { + ops.push({ op: 'hset', key, field, value }); + return pipe; + }, + hget(key, field) { + ops.push({ op: 'hget', key, field }); + return pipe; + }, + zadd(key, score, member) { + ops.push({ op: 'zadd', key, score, member }); + return pipe; + }, + async exec() { + const results = []; + for (const op of ops) { + if (op.op === 'hset') { + await self.hset(op.key, op.field, op.value); + results.push([null, 1]); + } else if (op.op === 'hget') { + const val = await self.hget(op.key, op.field); + results.push([null, val]); + } else if (op.op === 'zadd') { + await self.zadd(op.key, op.score, op.member); + results.push([null, 1]); + } + } + return results; + }, + }; + return pipe; + } +} + +// ── Minimal SegmentJudgment shape (matching segment-judgment-engine) ── + +function makeJudgment(partial) { + return { + judgmentId: `j-${Math.random().toString(36).slice(2, 8)}`, + segmentId: 'S1', + segmentVersion: null, + window: { startMs: 0, endMs: 1000 }, + verdict: 'alive', + evidence: { + injectionCount: { value: 10, how_counted: 'fired-count' }, + violationCount: { value: 0, how_counted: 'event-log' }, + denominatorKind: 'fired-count', + eventRefs: [], + correlationConfidence: 'window', + }, + pressure: { observabilityDeadline: null, nextRequiredAction: null }, + producedBy: { domainId: 'eval:harness-ledger', runId: 'run1', evalCat: 'cat1' }, + ...partial, + }; +} + +describe('SegmentJudgmentCache', () => { + /** @type {import('../dist/domains/prompt-hooks/SegmentJudgmentCache.js').SegmentJudgmentCache} */ + let cache; + let redis; + + test('setup: import and create cache', async () => { + const mod = await import('../dist/domains/prompt-hooks/SegmentJudgmentCache.js'); + redis = new FakeRedis(); + cache = new mod.SegmentJudgmentCache(redis); + assert.ok(cache); + }); + + // ── Basic CRUD ─────────────────────────────────────────────── + + test('get returns null for unknown segment', async () => { + const result = await cache.get('unknown'); + assert.equal(result, null); + }); + + test('updateBatch stores and retrieves judgment', async () => { + await cache.updateBatch([makeJudgment({ segmentId: 'S1', verdict: 'alive' })]); + const cached = await cache.get('S1'); + assert.ok(cached); + assert.equal(cached.segmentId, 'S1'); + assert.equal(cached.verdict, 'alive'); + assert.equal(cached.injectionCount, 10); + assert.equal(cached.violationCount, 0); + }); + + test('updateBatch overwrites previous entry', async () => { + await cache.updateBatch([makeJudgment({ segmentId: 'S1', verdict: 'alive' })]); + await cache.updateBatch([makeJudgment({ segmentId: 'S1', verdict: 'dormant' })]); + const cached = await cache.get('S1'); + assert.equal(cached.verdict, 'dormant'); + }); + + test('updateBatch with empty array is a no-op', async () => { + await cache.updateBatch([]); // should not throw + }); + + // ── P1-2: segmentVersion preservation ──────────────────────── + + test('segmentVersion is preserved in cache (not dropped)', async () => { + await cache.updateBatch([makeJudgment({ segmentId: 'S2', segmentVersion: 1, verdict: 'alive' })]); + const cached = await cache.get('S2'); + assert.ok(cached, 'cached entry should exist'); + assert.equal(cached.segmentVersion, 1, 'segmentVersion must be preserved, not dropped'); + }); + + test('segmentVersion=null is preserved (not silently dropped)', async () => { + await cache.updateBatch([makeJudgment({ segmentId: 'S3', segmentVersion: null, verdict: 'dormant' })]); + const cached = await cache.get('S3'); + assert.ok(cached); + assert.equal(cached.segmentVersion, null, 'null segmentVersion should be preserved'); + }); + + // ── Batch read ─────────────────────────────────────────────── + + test('getBatch returns multiple cached entries', async () => { + redis = new FakeRedis(); + const mod = await import('../dist/domains/prompt-hooks/SegmentJudgmentCache.js'); + cache = new mod.SegmentJudgmentCache(redis); + + await cache.updateBatch([ + makeJudgment({ segmentId: 'A', verdict: 'alive', segmentVersion: 1 }), + makeJudgment({ segmentId: 'B', verdict: 'dormant', segmentVersion: 2 }), + ]); + + const batch = await cache.getBatch(['A', 'B', 'missing']); + assert.equal(batch.size, 2); + assert.equal(batch.get('A')?.verdict, 'alive'); + assert.equal(batch.get('B')?.verdict, 'dormant'); + assert.equal(batch.has('missing'), false); + }); + + test('getBatch with empty array returns empty map', async () => { + const batch = await cache.getBatch([]); + assert.equal(batch.size, 0); + }); + + // ── P1-2: judgment history ────────────────────────────────── + + test('updateBatch appends to history, getHistory returns all in time order', async () => { + redis = new FakeRedis(); + const mod = await import('../dist/domains/prompt-hooks/SegmentJudgmentCache.js'); + cache = new mod.SegmentJudgmentCache(redis); + + // Two separate eval runs for the same segment + await cache.updateBatch([ + makeJudgment({ segmentId: 'H1', verdict: 'dormant', window: { startMs: 0, endMs: 100 } }), + ]); + await cache.updateBatch([ + makeJudgment({ segmentId: 'H1', verdict: 'alive', window: { startMs: 100, endMs: 200 } }), + ]); + + const history = await cache.getHistory('H1'); + assert.equal(history.length, 2, 'should have 2 history entries'); + assert.equal(history[0].verdict, 'dormant', 'first entry (oldest) is dormant'); + assert.equal(history[0].evaluatedAt, 100); + assert.equal(history[1].verdict, 'alive', 'second entry (latest) is alive'); + assert.equal(history[1].evaluatedAt, 200); + }); + + test('getHistory returns empty for unknown segment', async () => { + const history = await cache.getHistory('nonexistent'); + assert.equal(history.length, 0); + }); + + // ── 判据②: eval window + denominatorKind provenance (F257 #6 slice 6c) ── + + test("round-trip preserves the judgment's OWN eval window [startMs,endMs)", async () => { + redis = new FakeRedis(); + const mod = await import('../dist/domains/prompt-hooks/SegmentJudgmentCache.js'); + cache = new mod.SegmentJudgmentCache(redis); + + await cache.updateBatch([ + makeJudgment({ segmentId: 'W1', verdict: 'alive', window: { startMs: 5000, endMs: 9000 } }), + ]); + const cached = await cache.get('W1'); + assert.ok(cached); + assert.deepEqual(cached.window, { startMs: 5000, endMs: 9000 }, 'eval window must survive the round-trip'); + assert.equal(cached.evaluatedAt, 9000, 'evaluatedAt stays = window.endMs (not a window substitute)'); + }); + + test('round-trip preserves denominatorKind', async () => { + redis = new FakeRedis(); + const mod = await import('../dist/domains/prompt-hooks/SegmentJudgmentCache.js'); + cache = new mod.SegmentJudgmentCache(redis); + + await cache.updateBatch([makeJudgment({ segmentId: 'W2', verdict: 'alive' })]); + const cached = await cache.get('W2'); + assert.equal(cached.denominatorKind, 'fired-count'); + }); + + test('history entries carry window + denominatorKind per version', async () => { + redis = new FakeRedis(); + const mod = await import('../dist/domains/prompt-hooks/SegmentJudgmentCache.js'); + cache = new mod.SegmentJudgmentCache(redis); + + await cache.updateBatch([ + makeJudgment({ segmentId: 'W3', verdict: 'dormant', window: { startMs: 0, endMs: 100 } }), + ]); + await cache.updateBatch([ + makeJudgment({ segmentId: 'W3', verdict: 'alive', window: { startMs: 100, endMs: 200 } }), + ]); + const history = await cache.getHistory('W3'); + assert.equal(history.length, 2); + assert.deepEqual(history[0].window, { startMs: 0, endMs: 100 }); + assert.deepEqual(history[1].window, { startMs: 100, endMs: 200 }); + assert.equal(history[0].denominatorKind, 'fired-count'); + }); + + test('legacy entry without window/denominatorKind reads back as explicit null (fail-visible, not guessed)', async () => { + redis = new FakeRedis(); + const mod = await import('../dist/domains/prompt-hooks/SegmentJudgmentCache.js'); + cache = new mod.SegmentJudgmentCache(redis); + + // Simulate a pre-6c Redis JSON: no window, no denominatorKind. + const legacy = { + segmentId: 'L1', + verdict: 'alive', + injectionCount: 3, + violationCount: 0, + correlationConfidence: 'window', + evaluatedAt: 7000, + runId: 'run-legacy', + segmentVersion: 1, + }; + await redis.hset('segment-judgment-latest', 'L1', JSON.stringify(legacy)); + await redis.zadd('segment-judgment-history:L1', 7000, JSON.stringify(legacy)); + + const cached = await cache.get('L1'); + assert.ok(cached); + assert.equal(cached.window, null, 'legacy window must be explicit null — never derived from evaluatedAt'); + assert.equal(cached.denominatorKind, null, 'legacy denominatorKind must be explicit null'); + + const history = await cache.getHistory('L1'); + assert.equal(history[0].window, null); + assert.equal(history[0].denominatorKind, null); + }); + + // ── 判据② P2-1 (sol R1): malformed-PRESENT provenance fields fail closed ── + // Missing fields are legacy (→ null). Present-but-malformed fields are + // forgery-grade input — the read boundary must NOT pass them to the UI + // (Invalid Date ~ Invalid Date, bogus denominator text). + + async function freshCache() { + redis = new FakeRedis(); + const mod = await import('../dist/domains/prompt-hooks/SegmentJudgmentCache.js'); + cache = new mod.SegmentJudgmentCache(redis); + return cache; + } + + function validEntry(overrides = {}) { + return { + segmentId: 'M1', + verdict: 'alive', + injectionCount: 3, + violationCount: 0, + correlationConfidence: 'window', + evaluatedAt: 7000, + runId: 'run-m', + segmentVersion: 1, + window: { startMs: 6000, endMs: 7000 }, + denominatorKind: 'fired-count', + ...overrides, + }; + } + + test('malformed window (string) normalizes to null, rest of entry survives', async () => { + const c = await freshCache(); + await redis.hset('segment-judgment-latest', 'M1', JSON.stringify(validEntry({ window: 'bad' }))); + const cached = await c.get('M1'); + assert.ok(cached, 'entry itself must survive — only the forged field is dropped'); + assert.equal(cached.window, null); + assert.equal(cached.denominatorKind, 'fired-count'); + }); + + test('malformed window (empty object / reversed range / array) normalizes to null', async () => { + const c = await freshCache(); + await redis.hset('segment-judgment-latest', 'E1', JSON.stringify(validEntry({ segmentId: 'E1', window: {} }))); + await redis.hset( + 'segment-judgment-latest', + 'E2', + JSON.stringify(validEntry({ segmentId: 'E2', window: { startMs: 9000, endMs: 1000 } })), + ); + await redis.hset('segment-judgment-latest', 'E3', JSON.stringify(validEntry({ segmentId: 'E3', window: [1, 2] }))); + assert.equal((await c.get('E1')).window, null, 'empty object is not a window'); + assert.equal((await c.get('E2')).window, null, 'startMs > endMs is not a legal [start,end) order'); + assert.equal((await c.get('E3')).window, null, 'array is not a window record'); + }); + + test('zero-length window (startMs === endMs) normalizes to null (sol R4 P2-1)', async () => { + const c = await freshCache(); + // judgment-schema-v1 defines a [start,end) sampling window — an empty + // interval has no sampleable instant, and canonical selectors/adapters + // uniformly reject windowEndMs <= windowStartMs. The cache read boundary + // must fail closed the same way instead of rendering `t ~ t` as a + // trusted coordinate. + await redis.hset( + 'segment-judgment-latest', + 'Z1', + JSON.stringify(validEntry({ segmentId: 'Z1', window: { startMs: 7000, endMs: 7000 } })), + ); + const cached = await c.get('Z1'); + assert.ok(cached, 'entry itself must survive — only the forged field is dropped'); + assert.equal(cached.window, null, 'zero-length [t,t) window is malformed, not a legal coordinate'); + assert.equal(cached.denominatorKind, 'fired-count'); + + await redis.zadd( + 'segment-judgment-history:Z1', + 7000, + JSON.stringify(validEntry({ segmentId: 'Z1', window: { startMs: 7000, endMs: 7000 } })), + ); + const history = await c.getHistory('Z1'); + assert.equal(history[0].window, null, 'history seam applies the same interval invariant'); + }); + + test('malformed denominatorKind (number / unknown string) normalizes to null', async () => { + const c = await freshCache(); + await redis.hset( + 'segment-judgment-latest', + 'D1', + JSON.stringify(validEntry({ segmentId: 'D1', denominatorKind: 7 })), + ); + await redis.hset( + 'segment-judgment-latest', + 'D2', + JSON.stringify(validEntry({ segmentId: 'D2', denominatorKind: 'typed-fact' })), + ); + assert.equal((await c.get('D1')).denominatorKind, null, 'non-string denominator must not reach the UI'); + assert.equal((await c.get('D2')).denominatorKind, null, 'off-domain denominator must not reach the UI'); + }); + + test('gap kind distinguishes legacy-missing from invalid-present (sol R5 P2)', async () => { + const c = await freshCache(); + // legacy: fields absent entirely + const legacy = validEntry({ segmentId: 'G1' }); + delete legacy.window; + delete legacy.denominatorKind; + await redis.hset('segment-judgment-latest', 'G1', JSON.stringify(legacy)); + // forged: fields present but malformed + await redis.hset( + 'segment-judgment-latest', + 'G2', + JSON.stringify(validEntry({ segmentId: 'G2', window: { startMs: 7000, endMs: 7000 }, denominatorKind: 'bogus' })), + ); + // valid: fields present and well-formed + await redis.hset('segment-judgment-latest', 'G3', JSON.stringify(validEntry({ segmentId: 'G3' }))); + + const g1 = await c.get('G1'); + assert.equal(g1.window, null); + assert.equal(g1.windowGap, 'legacy-missing', 'absent fields are a legacy gap'); + assert.equal(g1.denominatorGap, 'legacy-missing'); + + const g2 = await c.get('G2'); + assert.equal(g2.window, null); + assert.equal(g2.windowGap, 'invalid-present', 'corrupted provenance is NOT a legacy gap'); + assert.equal(g2.denominatorKind, null); + assert.equal(g2.denominatorGap, 'invalid-present'); + + const g3 = await c.get('G3'); + assert.deepEqual(g3.window, { startMs: 6000, endMs: 7000 }); + assert.equal(g3.windowGap, null, 'well-formed provenance has no gap'); + assert.equal(g3.denominatorGap, null); + }); + + // ── P2 (sol R6): presence matrix — absent vs explicit-null vs valid vs invalid ── + // `raw == null` cannot distinguish a field that is ABSENT (legacy pre-6c + // entry) from a field that is PRESENT with value null. The producer never + // writes null, so present-null is malformed-present → 'invalid-present', + // never 'legacy-missing'. Classification must be by own-property presence. + + test('presence matrix: explicit-null → invalid-present (not legacy-missing) across get/getBatch/getHistory', async () => { + const c = await freshCache(); + // absent: pre-6c legacy entry (keys missing entirely) + const absent = validEntry({ segmentId: 'P-absent' }); + delete absent.window; + delete absent.denominatorKind; + // explicit-null: corrupted entry — producer never writes null + const explicitNull = validEntry({ segmentId: 'P-null', window: null, denominatorKind: null }); + // valid: well-formed producer write + const valid = validEntry({ segmentId: 'P-valid' }); + // invalid non-null: forged values + const invalid = validEntry({ + segmentId: 'P-invalid', + window: { startMs: 7000, endMs: 7000 }, + denominatorKind: 'bogus', + }); + + for (const entry of [absent, explicitNull, valid, invalid]) { + await redis.hset('segment-judgment-latest', entry.segmentId, JSON.stringify(entry)); + await redis.zadd(`segment-judgment-history:${entry.segmentId}`, 7000, JSON.stringify(entry)); + } + + const expectGaps = (label, j, windowGap, denominatorGap) => { + assert.ok(j, `${label}: entry must survive`); + assert.equal(j.windowGap, windowGap, `${label}: windowGap`); + assert.equal(j.denominatorGap, denominatorGap, `${label}: denominatorGap`); + }; + + // get seam + expectGaps('get/absent', await c.get('P-absent'), 'legacy-missing', 'legacy-missing'); + expectGaps('get/explicit-null', await c.get('P-null'), 'invalid-present', 'invalid-present'); + expectGaps('get/valid', await c.get('P-valid'), null, null); + expectGaps('get/invalid', await c.get('P-invalid'), 'invalid-present', 'invalid-present'); + + // getBatch seam + const batch = await c.getBatch(['P-absent', 'P-null', 'P-valid', 'P-invalid']); + expectGaps('getBatch/absent', batch.get('P-absent'), 'legacy-missing', 'legacy-missing'); + expectGaps('getBatch/explicit-null', batch.get('P-null'), 'invalid-present', 'invalid-present'); + expectGaps('getBatch/valid', batch.get('P-valid'), null, null); + expectGaps('getBatch/invalid', batch.get('P-invalid'), 'invalid-present', 'invalid-present'); + + // getHistory seam + expectGaps('getHistory/absent', (await c.getHistory('P-absent'))[0], 'legacy-missing', 'legacy-missing'); + expectGaps('getHistory/explicit-null', (await c.getHistory('P-null'))[0], 'invalid-present', 'invalid-present'); + expectGaps('getHistory/valid', (await c.getHistory('P-valid'))[0], null, null); + expectGaps('getHistory/invalid', (await c.getHistory('P-invalid'))[0], 'invalid-present', 'invalid-present'); + }); + + test('non-record raw (JSON array) is rejected entirely across get/getBatch/getHistory', async () => { + const c = await freshCache(); + await redis.hset('segment-judgment-latest', 'A1', JSON.stringify([])); + await redis.zadd('segment-judgment-history:A1', 7000, JSON.stringify([])); + assert.equal(await c.get('A1'), null, 'array raw must not be cast into a CachedJudgment'); + const batch = await c.getBatch(['A1']); + assert.equal(batch.has('A1'), false); + assert.equal((await c.getHistory('A1')).length, 0); + }); + + test('getBatch + getHistory apply the same fail-closed normalization per entry', async () => { + const c = await freshCache(); + await redis.hset('segment-judgment-latest', 'B1', JSON.stringify(validEntry({ segmentId: 'B1', window: {} }))); + await redis.hset('segment-judgment-latest', 'B2', JSON.stringify(validEntry({ segmentId: 'B2' }))); + const batch = await c.getBatch(['B1', 'B2']); + assert.equal(batch.get('B1').window, null); + assert.deepEqual(batch.get('B2').window, { startMs: 6000, endMs: 7000 }); + + await redis.zadd( + 'segment-judgment-history:B1', + 7000, + JSON.stringify(validEntry({ segmentId: 'B1', denominatorKind: 'bogus' })), + ); + const history = await c.getHistory('B1'); + assert.equal(history.length, 1); + assert.equal(history[0].denominatorKind, null); + }); +}); diff --git a/packages/api/test/segment-judgment-engine.test.js b/packages/api/test/segment-judgment-engine.test.js new file mode 100644 index 0000000000..8557688d26 --- /dev/null +++ b/packages/api/test/segment-judgment-engine.test.js @@ -0,0 +1,190 @@ +/** + * F257 Phase D — segment-judgment-engine unit tests. + * + * R7 regression: per-version eval grouping. + * The engine must produce separate judgments for traces with different versions + * of the same segment within the same eval window. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +// ── FakeTraceStore ── + +class FakeTraceStore { + constructor() { + this.traces = new Map(); // threadId → traces[] + } + + addTrace(trace) { + const list = this.traces.get(trace.threadId) ?? []; + list.push(trace); + this.traces.set(trace.threadId, list); + } + + async queryWindow(threadId, startMs, endMs) { + const list = this.traces.get(threadId) ?? []; + return list.filter((t) => t.timestamp >= startMs && t.timestamp <= endMs); + } +} + +function makeTrace(threadId, catId, timestamp, segments) { + return { threadId, catId, timestamp, segments }; +} + +function makeSeg(segmentId, opts = {}) { + return { + segmentId, + status: opts.status ?? 'observed', + pipelineStatus: opts.pipelineStatus ?? 'fired', + version: opts.version ?? undefined, + }; +} + +function makeSnapshot(startMs, endMs) { + return { + evalRunId: 'run-test-001', + totalEvents: 0, + window: { startMs, endMs }, + producedAt: '2026-07-14T00:00:00Z', + guardMetrics: {}, + guardDetails: [], + evidenceGaps: [], + evidenceLevel: 'adequate', + }; +} + +describe('produceSegmentJudgments', () => { + test('same segment with two different versions produces two judgments (R7)', async () => { + const mod = await import('../dist/infrastructure/harness-eval/segment-judgment-engine.js'); + const traceStore = new FakeTraceStore(); + + // v1 trace: segment 'sys-guard' fires as version 2 + traceStore.addTrace(makeTrace('t1', 'cat-a', 1000, [makeSeg('sys-guard', { version: 2 })])); + // v2 trace: same segment fires as version 3 + traceStore.addTrace(makeTrace('t1', 'cat-a', 2000, [makeSeg('sys-guard', { version: 3 })])); + + const judgments = await mod.produceSegmentJudgments( + { traceStore }, + { + snapshot: makeSnapshot(0, 5000), + evalCat: 'eval-cat', + threadIds: ['t1'], + }, + ); + + assert.equal(judgments.length, 2, 'should produce 2 separate judgments, one per version'); + const versions = judgments.map((j) => j.segmentVersion).sort(); + assert.deepEqual(versions, [2, 3], 'each judgment carries its own version'); + + // Each judgment should have injectionCount=1 (not 2) + for (const j of judgments) { + assert.equal(j.evidence.injectionCount.value, 1, `version ${j.segmentVersion} should count only its own traces`); + assert.equal(j.segmentId, 'sys-guard'); + assert.equal(j.verdict, 'alive'); + } + }); + + test('traces without version group together (backward compat)', async () => { + const mod = await import('../dist/infrastructure/harness-eval/segment-judgment-engine.js'); + const traceStore = new FakeTraceStore(); + + traceStore.addTrace(makeTrace('t1', 'cat-a', 1000, [makeSeg('sys-guard')])); + traceStore.addTrace(makeTrace('t1', 'cat-a', 2000, [makeSeg('sys-guard')])); + + const judgments = await mod.produceSegmentJudgments( + { traceStore }, + { + snapshot: makeSnapshot(0, 5000), + evalCat: 'eval-cat', + threadIds: ['t1'], + }, + ); + + assert.equal(judgments.length, 1, 'traces without version group into single judgment'); + assert.equal(judgments[0].evidence.injectionCount.value, 2); + assert.equal(judgments[0].segmentVersion, null); + }); + + test('mixed versioned and unversioned traces produce correct grouping', async () => { + const mod = await import('../dist/infrastructure/harness-eval/segment-judgment-engine.js'); + const traceStore = new FakeTraceStore(); + + // 2 traces with version=2, 1 trace without version, 1 trace with version=3 + traceStore.addTrace(makeTrace('t1', 'cat-a', 1000, [makeSeg('hook-a', { version: 2 })])); + traceStore.addTrace(makeTrace('t1', 'cat-a', 2000, [makeSeg('hook-a', { version: 2 })])); + traceStore.addTrace(makeTrace('t1', 'cat-a', 3000, [makeSeg('hook-a')])); + traceStore.addTrace(makeTrace('t1', 'cat-a', 4000, [makeSeg('hook-a', { version: 3 })])); + + const judgments = await mod.produceSegmentJudgments( + { traceStore }, + { + snapshot: makeSnapshot(0, 5000), + evalCat: 'eval-cat', + threadIds: ['t1'], + }, + ); + + assert.equal(judgments.length, 3, 'v2 + null + v3 = 3 groups'); + + const byVersion = new Map(judgments.map((j) => [j.segmentVersion, j])); + assert.equal(byVersion.get(2).evidence.injectionCount.value, 2, 'v2 has 2 traces'); + assert.equal(byVersion.get(null).evidence.injectionCount.value, 1, 'null-version has 1 trace'); + assert.equal(byVersion.get(3).evidence.injectionCount.value, 1, 'v3 has 1 trace'); + }); + + test('skip IDs are excluded from judgments', async () => { + const mod = await import('../dist/infrastructure/harness-eval/segment-judgment-engine.js'); + const traceStore = new FakeTraceStore(); + + traceStore.addTrace( + makeTrace('t1', 'cat-a', 1000, [ + makeSeg('per-turn-aggregate'), + makeSeg('session-init-pack-only'), + makeSeg('real-hook', { version: 1 }), + ]), + ); + + const judgments = await mod.produceSegmentJudgments( + { traceStore }, + { + snapshot: makeSnapshot(0, 5000), + evalCat: 'eval-cat', + threadIds: ['t1'], + }, + ); + + assert.equal(judgments.length, 1, 'only real-hook should produce a judgment'); + assert.equal(judgments[0].segmentId, 'real-hook'); + }); + + test('guard event correlation works with per-version grouping', async () => { + const mod = await import('../dist/infrastructure/harness-eval/segment-judgment-engine.js'); + const traceStore = new FakeTraceStore(); + + // v2 fires at t=1000, v3 fires at t=200000 (200s later) + // Correlation window is ±120s (120,000ms) + traceStore.addTrace(makeTrace('t1', 'cat-a', 1000, [makeSeg('hook-a', { version: 2 })])); + traceStore.addTrace(makeTrace('t1', 'cat-a', 200000, [makeSeg('hook-a', { version: 3 })])); + + // Guard event at t=1050 — should correlate with v2 trace (within ±120s), + // but NOT with v3 trace (|200000-1050| = 198950ms > 120000ms window) + const guardEvents = [{ eventId: 'g1', guardId: 'guard-x', threadId: 't1', catId: 'cat-a', timestamp: 1050 }]; + + const judgments = await mod.produceSegmentJudgments( + { traceStore }, + { + snapshot: makeSnapshot(0, 300000), + evalCat: 'eval-cat', + threadIds: ['t1'], + rawGuardEvents: guardEvents, + }, + ); + + assert.equal(judgments.length, 2); + const v2 = judgments.find((j) => j.segmentVersion === 2); + const v3 = judgments.find((j) => j.segmentVersion === 3); + assert.equal(v2.evidence.violationCount.value, 1, 'v2 should have 1 correlated violation'); + assert.equal(v3.evidence.violationCount.value, 0, 'v3 should have 0 violations (guard event too far)'); + }); +}); diff --git a/packages/api/test/session-bind-history-import.test.js b/packages/api/test/session-bind-history-import.test.js index 8742f5104f..dd6c24e68b 100644 --- a/packages/api/test/session-bind-history-import.test.js +++ b/packages/api/test/session-bind-history-import.test.js @@ -99,7 +99,7 @@ describe('Session bind history import', () => { try { const thread = await threadStore.create('user-1', 'Test'); - await createSealedTranscript({ + const sealedSession = await createSealedTranscript({ sessionChainStore, transcriptWriter, threadId: thread.id, @@ -135,6 +135,12 @@ describe('Session bind history import', () => { assert.equal(stored.length, 1); assert.equal(stored[0]?.catId, 'opus'); assert.equal(stored[0]?.content, '历史里的布偶猫回答'); + assert.equal(stored[0]?.provenance?.observation, 'derived'); + assert.match( + stored[0]?.provenance?.sourceRef ?? '', + new RegExp(`^transcript:${sealedSession.id}:\\d+$`), + 'history import declares its transcript lineage instead of posing as a fresh observation', + ); // F194 Phase Z9 AC-Z25 (KD-28): history import now stamps turnInvocationId // explicitly (= invocationId for history records — they have only one identity). assert.deepEqual(stored[0]?.extra?.stream, { invocationId: 'inv-1', turnInvocationId: 'inv-1' }); diff --git a/packages/api/test/soft-delete.test.js b/packages/api/test/soft-delete.test.js index 4c82e692ca..0dc84b3829 100644 --- a/packages/api/test/soft-delete.test.js +++ b/packages/api/test/soft-delete.test.js @@ -29,6 +29,7 @@ function seedMessages(store) { for (let i = 0; i < 5; i++) { msgs.push( store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: `message ${i}`, @@ -527,6 +528,7 @@ describe('Authorization: DELETE /api/messages/:id', () => { const socketManager = createMockSocketManager(); // Add a message from a different user const catMsg = messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'cat-opus', catId: 'opus', content: 'cat reply', diff --git a/packages/api/test/system-prompt-builder.test.js b/packages/api/test/system-prompt-builder.test.js index a702afcf60..f44a98205e 100644 --- a/packages/api/test/system-prompt-builder.test.js +++ b/packages/api/test/system-prompt-builder.test.js @@ -677,7 +677,8 @@ describe('SystemPromptBuilder', () => { const identity = buildStaticIdentity('opus'); // gpt52 keeps teamStrengths and has no explicit caution override in current config. assert.ok(identity.includes('架构思考'), 'Should include gpt52 teamStrengths'); - assert.ok(identity.includes('| 缅因猫/砚砚(GPT-5.4) |') || identity.includes('| 缅因猫/砚砚 |')); + // P1-4: codex nickname removed → roster shows displayName only (no /砚砚 suffix) + assert.ok(identity.includes('| 缅因猫 |'), 'codex roster label must be displayName only (nickname=null)'); // gemini has caution about no coding assert.ok(identity.includes('禁止写代码'), 'Should include gemini caution'); } finally { diff --git a/packages/api/test/thread-branch-permission.test.js b/packages/api/test/thread-branch-permission.test.js index f8478483b1..7984202b2e 100644 --- a/packages/api/test/thread-branch-permission.test.js +++ b/packages/api/test/thread-branch-permission.test.js @@ -66,6 +66,7 @@ describe('F109: Branch from system-created thread', () => { // Seed a system-created thread with a message const msg = messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'hello from system thread', @@ -111,6 +112,7 @@ describe('F109: Branch from system-created thread', () => { const socketManager = createMockSocketManager(); const msg = messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'message', diff --git a/packages/api/test/thread-branch.test.js b/packages/api/test/thread-branch.test.js index 8c9d7157e1..88a8b158c6 100644 --- a/packages/api/test/thread-branch.test.js +++ b/packages/api/test/thread-branch.test.js @@ -91,6 +91,7 @@ function seedThread(messageStore, threadStore) { const msgs = []; msgs.push( messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: '你好', @@ -101,6 +102,7 @@ function seedThread(messageStore, threadStore) { ); msgs.push( messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'opus', content: '你好!有什么可以帮你?', @@ -111,6 +113,7 @@ function seedThread(messageStore, threadStore) { ); msgs.push( messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: '帮我写个登录页', @@ -121,6 +124,7 @@ function seedThread(messageStore, threadStore) { ); msgs.push( messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'opus', content: '好的,已创建登录页...', @@ -150,6 +154,68 @@ async function waitFor(predicate, timeoutMs = 500, intervalMs = 10) { } describe('POST /api/threads/:id/branch (ADR-008 D4 / S7)', () => { + it('sol R4 P1-2: branch copy preserves the source author declaration (catId:null system relay stays system)', async () => { + const messageStore = new MessageStore(); + const threadStore = createMockThreadStore(); + seedThread(messageStore, threadStore); + // a catId:null SYSTEM surface (relay/notice): the old inference + // `src.catId ? 'cat' : 'user'` would forge author:user out of this and + // feed its magic words into the exact counter + const relay = messageStore.append({ + provenance: { author: 'system', routed: false, observation: 'original' }, + userId: 'user-1', + catId: null, + content: '[系统relay] 有猫说了 脚手架', + mentions: [], + timestamp: 2000, + threadId: 'thread-orig', + }); + const { app } = await setupApp(messageStore, threadStore); + const res = await app.inject({ + method: 'POST', + url: '/api/threads/thread-orig/branch', + payload: { fromMessageId: relay.id, userId: 'user-1' }, + }); + assert.equal(res.statusCode, 201); + const copies = messageStore.getByThread(res.json().threadId, 100); + const copy = copies[copies.length - 1]; + assert.equal(copy.provenance.author, 'system', 'author axis is copied from the source, never rebuilt from catId'); + assert.equal(copy.provenance.routed, false, 'no parser ran over the copy'); + assert.equal(copy.provenance.observation, 'derived', 'copied history is context, not a new observation'); + assert.equal(copy.provenance.sourceRef, `message:${relay.id}`); + assert.equal(copy.routingFact, undefined, 'authority fact belongs to the original message only'); + }); + + it('sol R4 P1-2: branch copy declares author unknown for a legacy source without provenance', async () => { + const messageStore = new MessageStore(); + const threadStore = createMockThreadStore(); + seedThread(messageStore, threadStore); + // legacy message written before the provenance contract — injected directly + // (the append boundary itself now rejects declaration-less writes) + messageStore.messages.push({ + id: 'legacy-msg-1', + threadId: 'thread-orig', + userId: 'user-1', + catId: null, + content: '古老的消息 绕路了', + mentions: [], + timestamp: 3000, + }); + const { app } = await setupApp(messageStore, threadStore); + const res = await app.inject({ + method: 'POST', + url: '/api/threads/thread-orig/branch', + payload: { fromMessageId: 'legacy-msg-1', userId: 'user-1' }, + }); + assert.equal(res.statusCode, 201); + const copies = messageStore.getByThread(res.json().threadId, 100); + const copy = copies[copies.length - 1]; + assert.equal(copy.provenance.author, 'unknown', 'unverifiable authorship is declared, not guessed as user'); + assert.equal(copy.provenance.routed, false); + assert.equal(copy.provenance.observation, 'derived'); + assert.equal(copy.provenance.sourceRef, 'message:legacy-msg-1'); + }); + it('creates branch with all messages up to fromMessageId', async () => { const messageStore = new MessageStore(); const threadStore = createMockThreadStore(); @@ -205,6 +271,13 @@ describe('POST /api/threads/:id/branch (ADR-008 D4 / S7)', () => { const branchMsgs = messageStore.getByThread(body.threadId, 100); assert.equal(branchMsgs.length, 3); assert.equal(branchMsgs[2].content, '帮我写个注册页'); // edited + assert.equal(branchMsgs[0].provenance.observation, 'derived', 'copied history stays derived'); + assert.equal(branchMsgs[0].provenance.sourceRef, `message:${msgs[0].id}`); + assert.deepEqual( + branchMsgs[2].provenance, + { author: 'user', routed: false, observation: 'original' }, + 'the user-edited final message is a new original observation', + ); await app.close(); }); @@ -293,6 +366,7 @@ describe('POST /api/threads/:id/branch (ADR-008 D4 / S7)', () => { // Create a message in a different thread const otherMsg = messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'other thread', @@ -507,6 +581,7 @@ describe('POST /api/threads/:id/branch (ADR-008 D4 / S7)', () => { createdBy: 'user-1', }); const msg = messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'hi', @@ -543,6 +618,7 @@ describe('POST /api/threads/:id/branch (ADR-008 D4 / S7)', () => { // User message (no origin) messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, content: 'Hello', @@ -552,6 +628,7 @@ describe('POST /api/threads/:id/branch (ADR-008 D4 / S7)', () => { }); // Opus stream message (origin: 'stream' — should be hidden in play mode) messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'opus', content: 'thinking...', @@ -562,6 +639,7 @@ describe('POST /api/threads/:id/branch (ADR-008 D4 / S7)', () => { }); // Codex callback message (origin: 'callback' — should be visible) const m3 = messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, userId: 'user-1', catId: 'codex', content: 'result', diff --git a/packages/api/test/thread-context-workflow-sop.test.js b/packages/api/test/thread-context-workflow-sop.test.js index a784365117..1cb69f65ec 100644 --- a/packages/api/test/thread-context-workflow-sop.test.js +++ b/packages/api/test/thread-context-workflow-sop.test.js @@ -97,6 +97,7 @@ describe('GET thread-context with workflowSop', () => { // Add a message so we have content messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, threadId: thread.id, @@ -159,6 +160,7 @@ describe('GET thread-context with workflowSop', () => { }); messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, threadId: thread.id, @@ -187,6 +189,7 @@ describe('GET thread-context with workflowSop', () => { const { invocationId, callbackToken } = await registry.create('user-1', 'opus', thread.id); messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, threadId: thread.id, @@ -222,6 +225,7 @@ describe('GET thread-context with workflowSop', () => { // Add a message so thread-context has content messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-2', catId: null, threadId: otherThread.id, @@ -251,6 +255,7 @@ describe('GET thread-context with workflowSop', () => { const { invocationId, callbackToken } = await registry.create('user-1', 'opus', thread.id); messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user-1', catId: null, threadId: thread.id, diff --git a/packages/api/test/threads-endpoint.test.js b/packages/api/test/threads-endpoint.test.js index 7c6ccb9d12..43dde25008 100644 --- a/packages/api/test/threads-endpoint.test.js +++ b/packages/api/test/threads-endpoint.test.js @@ -994,6 +994,7 @@ describe('Thread soft-delete preserves data (Phase D)', () => { // Add some messages messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'alice', catId: null, content: 'test message 1', @@ -1002,6 +1003,7 @@ describe('Thread soft-delete preserves data (Phase D)', () => { threadId, }); messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'alice', catId: null, content: 'test message 2', @@ -1446,6 +1448,7 @@ describe('GET /api/messages with threadId', () => { it('returns only messages for the specified thread', async () => { messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'default-user', catId: null, content: 'thread-a msg', @@ -1454,6 +1457,7 @@ describe('GET /api/messages with threadId', () => { threadId: 'thread-a', }); messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'default-user', catId: null, content: 'thread-b msg', @@ -1473,6 +1477,7 @@ describe('GET /api/messages with threadId', () => { it('thread query filters by userId (regression: cross-user leak)', async () => { messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'alice', catId: null, content: 'alice in thread', @@ -1481,6 +1486,7 @@ describe('GET /api/messages with threadId', () => { threadId: 'shared-thread', }); messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'bob', catId: null, content: 'bob in thread', @@ -1502,6 +1508,7 @@ describe('GET /api/messages with threadId', () => { it('thread-scoped pagination with before cursor', async () => { for (let i = 0; i < 5; i++) { messageStore.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'default-user', catId: null, content: `t-msg ${i}`, diff --git a/packages/api/test/trace-bridge.test.js b/packages/api/test/trace-bridge.test.js new file mode 100644 index 0000000000..ac4f5cf6ec --- /dev/null +++ b/packages/api/test/trace-bridge.test.js @@ -0,0 +1,267 @@ +/** + * F257 Phase A Line B — Trace Persistence Bridge tests + * + * Verifies pipeline PipelineResult → v0 InjectionTraceSummary/Detail conversion. + */ + +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { describe, test } from 'node:test'; + +const { buildFromPipeline } = await import('../dist/domains/prompt-hooks/trace-bridge.js'); + +/** Replicate HookPipeline.assemblePatches exactly for test assertions. */ +function expectedAssembledHash(contents) { + const combined = contents.join('\n\n'); + return createHash('sha256').update(combined).digest('hex').slice(0, 16); +} + +// ── Test data factories ── + +/** Build a minimal PipelineResult with fired events */ +function makePipelineResult(hooks = []) { + const events = hooks.map((h) => ({ + hookId: h.id, + status: h.status ?? 'fired', + contentHash: h.hash ?? `hash-${h.id}`, + tokenEstimate: h.tokens ?? 100, + })); + const patches = hooks + .filter((h) => (h.status ?? 'fired') === 'fired') + .map((h) => ({ + hookId: h.id, + content: h.content ?? `content-for-${h.id}`, + position: 'prepend', + })); + return { events, patches }; +} + +const META = { + turnId: 'turn-001', + threadId: 'thread-abc', + catId: 'opus-47', + hasNativeL0: false, +}; + +describe('trace-bridge buildFromPipeline', () => { + test('returns null when both session and turn are null', () => { + const result = buildFromPipeline(null, null, META); + assert.equal(result, null); + }); + + test('builds summary + detail from session-only pipeline result', () => { + const session = makePipelineResult([ + { id: 'hook-a', tokens: 50, content: 'hello' }, + { id: 'hook-b', tokens: 30, content: 'world' }, + ]); + const result = buildFromPipeline(session, null, META); + + assert.ok(result, 'result should not be null'); + const { summary, detail } = result; + + // Summary checks + assert.equal(summary.turnId, 'turn-001'); + assert.equal(summary.threadId, 'thread-abc'); + assert.equal(summary.catId, 'opus-47'); + assert.equal(summary.segments.length, 2); + assert.equal(summary.totalSegmentsObserved, 2); + assert.equal(summary.totalSegmentsAbsent, 0); + assert.equal(summary.totalTokenEstimate, 80); + assert.equal(summary.totalCharCount, 10); // 'hello' + 'world' = 5 + 5 + + // Detail checks + assert.equal(detail.turnId, 'turn-001'); + assert.equal(detail.sessionTokenEstimate, 80); + assert.equal(detail.turnTokenEstimate, 0); + assert.equal(detail.sessionCharCount, 10); + assert.equal(detail.turnCharCount, 0); + }); + + test('builds from turn-only pipeline result', () => { + const turn = makePipelineResult([{ id: 'turn-hook', tokens: 200, content: 'turn-content' }]); + const result = buildFromPipeline(null, turn, META); + + assert.ok(result); + const { summary, detail } = result; + + assert.equal(summary.segments.length, 1); + assert.equal(summary.totalTokenEstimate, 200); + assert.equal(detail.sessionTokenEstimate, 0); + assert.equal(detail.turnTokenEstimate, 200); + assert.equal(detail.turnCharCount, 12); // 'turn-content'.length + }); + + test('builds from both session + turn pipeline results', () => { + const session = makePipelineResult([{ id: 'session-h', tokens: 100, content: 'sess' }]); + const turn = makePipelineResult([{ id: 'turn-h', tokens: 50, content: 'trn' }]); + const result = buildFromPipeline(session, turn, META); + + assert.ok(result); + const { summary, detail } = result; + + assert.equal(summary.segments.length, 2); + assert.equal(summary.totalTokenEstimate, 150); + assert.equal(summary.totalCharCount, 7); // 'sess' + 'trn' + assert.equal(detail.sessionTokenEstimate, 100); + assert.equal(detail.turnTokenEstimate, 50); + }); + + test('skipped hooks produce absent segments', () => { + const session = makePipelineResult([ + { id: 'active', status: 'fired', tokens: 100, content: 'active-content' }, + { id: 'skipped', status: 'skipped', tokens: 0 }, + { id: 'disabled', status: 'disabled', tokens: 0 }, + ]); + const result = buildFromPipeline(session, null, META); + + assert.ok(result); + const { summary } = result; + + assert.equal(summary.totalSegmentsObserved, 1); + assert.equal(summary.totalSegmentsAbsent, 2); + + const observed = summary.segments.filter((s) => s.status === 'observed'); + const absent = summary.segments.filter((s) => s.status === 'absent'); + assert.equal(observed.length, 1); + assert.equal(observed[0].segmentId, 'active'); + assert.equal(absent.length, 2); + }); + + test('session segments have stage session-init, turn segments have per-turn', () => { + const session = makePipelineResult([{ id: 'sh', tokens: 10, content: 'x' }]); + const turn = makePipelineResult([{ id: 'th', tokens: 10, content: 'y' }]); + const result = buildFromPipeline(session, turn, META); + + assert.ok(result); + const sessionSeg = result.summary.segments.find((s) => s.segmentId === 'sh'); + const turnSeg = result.summary.segments.find((s) => s.segmentId === 'th'); + assert.equal(sessionSeg.stage, 'session-init'); + assert.equal(turnSeg.stage, 'per-turn'); + }); + + test('delivery decisions reflect hasNativeL0 flag', () => { + const session = makePipelineResult([{ id: 'h', tokens: 10, content: 'x' }]); + + // Without native L0 + const result1 = buildFromPipeline(session, null, { ...META, hasNativeL0: false }); + assert.ok(result1); + const sessionDelivery1 = result1.summary.delivery.find((d) => d.stage === 'session-init'); + assert.equal(sessionDelivery1.channel, 'message-prepend'); + + // With native L0 + const result2 = buildFromPipeline(session, null, { ...META, hasNativeL0: true }); + assert.ok(result2); + const sessionDelivery2 = result2.summary.delivery.find((d) => d.stage === 'session-init'); + assert.equal(sessionDelivery2.channel, 'pack-only'); + }); + + test('sessionContentHash matches HookPipeline.assemblePatches semantics', () => { + // P1 regression: multi-hook stage must hash ALL assembled content + const session = makePipelineResult([ + { id: 'D1', status: 'fired', hash: 'hash-d1', tokens: 50, content: 'alpha' }, + { id: 'D2', status: 'fired', hash: 'hash-d2', tokens: 30, content: 'beta' }, + ]); + const result = buildFromPipeline(session, null, META); + + assert.ok(result); + // Must match assemblePatches: original order, '\n\n' separator + const expected = expectedAssembledHash(['alpha', 'beta']); + assert.equal(result.detail.sessionContentHash, expected); + assert.equal(result.detail.turnContentHash, null); + }); + + test('P2 regression: D2→D10 hash uses manifest order not lexicographic', () => { + // Terra's exact repro: D2 before D10 in manifest order. + // Lexicographic sort would put D10 before D2 (wrong). + const session = makePipelineResult([ + { id: 'D2', status: 'fired', tokens: 10, content: 'first' }, + { id: 'D10', status: 'fired', tokens: 10, content: 'second' }, + ]); + const result = buildFromPipeline(session, null, META); + + assert.ok(result); + // Must be hash("first\n\nsecond"), NOT hash("secondfirst") or hash("second\n\nfirst") + const correctHash = expectedAssembledHash(['first', 'second']); + const wrongLexHash = expectedAssembledHash(['second', 'first']); + assert.equal(result.detail.sessionContentHash, correctHash); + assert.notEqual(result.detail.sessionContentHash, wrongLexHash); + }); + + test('fired events carry version in ObservedSegment', () => { + // P1 regression: version must survive bridge for F257 evidence tuple + const session = makePipelineResult([{ id: 'h1', status: 'fired', tokens: 10, content: 'x' }]); + // Manually set version on the event (makePipelineResult doesn't set it) + session.events[0].version = 3; + const result = buildFromPipeline(session, null, META); + + assert.ok(result); + const seg = result.summary.segments[0]; + assert.equal(seg.version, 3); + assert.equal(seg.pipelineStatus, 'fired'); + }); + + test('skipped events carry reasonCode and reason', () => { + // P1 regression: skip reason must survive bridge + const session = makePipelineResult([{ id: 'h1', status: 'skipped', tokens: 0 }]); + // Manually add skipped-specific fields + session.events[0].reasonCode = 'no_thread_context'; + session.events[0].reason = 'Thread context unavailable'; + const result = buildFromPipeline(session, null, META); + + assert.ok(result); + const seg = result.summary.segments[0]; + assert.equal(seg.status, 'absent'); + assert.equal(seg.pipelineStatus, 'skipped'); + assert.equal(seg.reasonCode, 'no_thread_context'); + assert.equal(seg.reason, 'Thread context unavailable'); + }); + + test('disabled events carry disabledBy', () => { + // P1 regression: disable source must survive bridge + const session = makePipelineResult([{ id: 'h1', status: 'disabled', tokens: 0 }]); + session.events[0].disabledBy = 'operator'; + const result = buildFromPipeline(session, null, META); + + assert.ok(result); + const seg = result.summary.segments[0]; + assert.equal(seg.status, 'absent'); + assert.equal(seg.pipelineStatus, 'disabled'); + assert.equal(seg.disabledBy, 'operator'); + }); + + test('multi-hook mixed status: D1 fired + D2 skipped preserves both', () => { + // P1 regression: Terra's exact repro scenario + const session = makePipelineResult([ + { id: 'D1', status: 'fired', tokens: 100, content: 'content-D1' }, + { id: 'D2', status: 'skipped', tokens: 0 }, + ]); + session.events[0].version = 2; + session.events[1].reasonCode = 'resolver_false'; + session.events[1].reason = 'Resolver returned false'; + const result = buildFromPipeline(session, null, META); + + assert.ok(result); + assert.equal(result.summary.segments.length, 2); + + const d1 = result.summary.segments.find((s) => s.segmentId === 'D1'); + const d2 = result.summary.segments.find((s) => s.segmentId === 'D2'); + + assert.equal(d1.status, 'observed'); + assert.equal(d1.pipelineStatus, 'fired'); + assert.equal(d1.version, 2); + assert.equal(d1.charCount, 10); // 'content-D1'.length + + assert.equal(d2.status, 'absent'); + assert.equal(d2.pipelineStatus, 'skipped'); + assert.equal(d2.reasonCode, 'resolver_false'); + }); + + test('optional sessionId is included when provided', () => { + const session = makePipelineResult([{ id: 'h', tokens: 10, content: 'x' }]); + const metaWithSession = { ...META, sessionId: 'sess-42' }; + const result = buildFromPipeline(session, null, metaWithSession); + + assert.ok(result); + assert.equal(result.summary.sessionId, 'sess-42'); + }); +}); diff --git a/packages/api/test/whisper-visibility.test.js b/packages/api/test/whisper-visibility.test.js index 7c8931186d..be648e55c4 100644 --- a/packages/api/test/whisper-visibility.test.js +++ b/packages/api/test/whisper-visibility.test.js @@ -61,6 +61,7 @@ describe('MessageStore whisper', () => { test('append stores visibility and whisperTo', () => { const msg = store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user1', catId: null, content: 'secret message', @@ -77,6 +78,7 @@ describe('MessageStore whisper', () => { test('revealWhispers sets revealedAt on all whispers in thread', () => { store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user1', catId: null, content: 'public msg', @@ -85,6 +87,7 @@ describe('MessageStore whisper', () => { threadId: 'thread1', }); store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user1', catId: null, content: 'whisper 1', @@ -95,6 +98,7 @@ describe('MessageStore whisper', () => { whisperTo: ['opus'], }); store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user1', catId: null, content: 'whisper 2', @@ -105,6 +109,7 @@ describe('MessageStore whisper', () => { whisperTo: ['codex'], }); store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user1', catId: null, content: 'other thread whisper', @@ -135,6 +140,7 @@ describe('MessageStore whisper', () => { test('revealWhispers is idempotent', () => { store.append({ + provenance: { author: 'user', routed: false, observation: 'original' }, userId: 'user1', catId: null, content: 'whisper', From 610268d0e9125427d3bf535ac21e531a4a01933d Mon Sep 17 00:00:00 2001 From: mindfn Date: Wed, 29 Jul 2026 22:35:25 +0800 Subject: [PATCH 03/15] =?UTF-8?q?feat(f257):=20runtime=20wiring=20?= =?UTF-8?q?=E2=80=94=20lifeline=20routes,=20overrides,=20MCP=20tools=20and?= =?UTF-8?q?=20provenance=20enforcement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R6 split: C — runtime wiring. Adds segment-lifeline routes, prompt-injection override routes, makes MessageStore.provenance required, wires new routes in index.ts, and lands MCP server tools/tests. --- cat-template.json | 4 +- .../services/stores/ports/MessageStore.ts | 2 +- packages/api/src/index.ts | 49 + .../src/routes/prompt-injection-overrides.ts | 217 ++++ .../api/src/routes/segment-lifeline-chain.ts | 399 +++++++ .../api/src/routes/segment-lifeline-replay.ts | 321 +++++ packages/api/src/routes/segment-lifeline.ts | 427 +++++++ .../test/prompt-injection-overrides.test.js | 252 ++++ .../api/test/segment-lifeline-chain.test.js | 1043 +++++++++++++++++ .../api/test/segment-lifeline-replay.test.js | 884 ++++++++++++++ packages/api/test/segment-lifeline.test.js | 522 +++++++++ packages/mcp-server/src/server-toolsets.ts | 10 + .../mcp-server/src/tools/callback-outbox.ts | 5 + .../mcp-server/src/tools/callback-tools.ts | 67 +- .../src/tools/guard-rejection-report.ts | 51 + packages/mcp-server/src/tools/index.ts | 12 + .../src/tools/list-objectives-tool.ts | 57 + .../src/tools/publish-verdict-tool.ts | 81 +- .../src/tools/report-harness-signal-tool.ts | 122 ++ .../mcp-server/test/callback-retry.test.js | 78 ++ .../cross-post-message-targetcats.test.js | 36 +- .../test/hold-ball-no-retry-429.test.js | 197 ++++ .../test/list-objectives-tool.test.js | 77 ++ .../mcp-server/test/tool-registration.test.js | 20 +- 24 files changed, 4897 insertions(+), 36 deletions(-) create mode 100644 packages/api/src/routes/prompt-injection-overrides.ts create mode 100644 packages/api/src/routes/segment-lifeline-chain.ts create mode 100644 packages/api/src/routes/segment-lifeline-replay.ts create mode 100644 packages/api/src/routes/segment-lifeline.ts create mode 100644 packages/api/test/prompt-injection-overrides.test.js create mode 100644 packages/api/test/segment-lifeline-chain.test.js create mode 100644 packages/api/test/segment-lifeline-replay.test.js create mode 100644 packages/api/test/segment-lifeline.test.js create mode 100644 packages/mcp-server/src/tools/guard-rejection-report.ts create mode 100644 packages/mcp-server/src/tools/list-objectives-tool.ts create mode 100644 packages/mcp-server/src/tools/report-harness-signal-tool.ts create mode 100644 packages/mcp-server/test/hold-ball-no-retry-429.test.js create mode 100644 packages/mcp-server/test/list-objectives-tool.test.js diff --git a/cat-template.json b/cat-template.json index dc6e8bf9f0..e3da82a7dc 100644 --- a/cat-template.json +++ b/cat-template.json @@ -17,7 +17,7 @@ { "id": "maine-coon", "name": "缅因猫", - "nickname": "砚砚", + "nickname": null, "avatar": "/avatars/codex.png", "color": { "primary": "#5B8C5A", @@ -354,7 +354,7 @@ "catId": "codex", "name": "缅因猫", "displayName": "缅因猫", - "nickname": "砚砚", + "nickname": null, "avatar": "/avatars/codex.png", "color": { "primary": "#5B8C5A", diff --git a/packages/api/src/domains/cats/services/stores/ports/MessageStore.ts b/packages/api/src/domains/cats/services/stores/ports/MessageStore.ts index 406a39f360..c648b911fe 100644 --- a/packages/api/src/domains/cats/services/stores/ports/MessageStore.ts +++ b/packages/api/src/domains/cats/services/stores/ports/MessageStore.ts @@ -225,7 +225,7 @@ export const PROVENANCE_OBSERVATIONS = ['original', 'derived'] as const; */ export type AppendMessageInput = Omit & { threadId?: string; - provenance?: MessageProvenance; + provenance: MessageProvenance; /** Append may initialize only queued state; terminal delivery metadata belongs to transition methods. */ deliveryStatus?: 'queued'; /** diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index decf2b7053..ca36a72e4f 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -2146,6 +2146,55 @@ async function main(): Promise { judgmentCache: segmentJudgmentCache, }); + // F257 approval executor (KD-14 first leg): operator-gated override management. + { + const { promptInjectionOverrideRoutes } = await import('./routes/prompt-injection-overrides.js'); + await app.register(promptInjectionOverrideRoutes, { overrideStore: hookOverrideStore }); + } + + // F257 Phase D: Segment lifeline endpoint — read-model join for Console lifeline modal. + { + const { segmentLifelineRoutes } = await import('./routes/segment-lifeline.js'); + const { getCachedRegistry } = await import('./domains/prompt-hooks/PipelinePromptBuilder.js'); + const { getTemplateFileInfo, getTemplateOverlayPath } = await import( + './domains/cats/services/context/prompt-template-loader.js' + ); + const { existsSync } = await import('node:fs'); + await app.register(segmentLifelineRoutes, { + traceStore: injectionTraceStore, + guardRejectionLog, + overrideStore: hookOverrideStore, + judgmentCache: segmentJudgmentCache, + messageStore, + resolveManifestVersion: (segmentId) => getCachedRegistry()?.getHook(segmentId)?.manifest.version ?? 1, + resolveSegmentName: (segmentId) => getCachedRegistry()?.getHook(segmentId)?.manifest.name ?? segmentId, + resolveSegmentManifest: (segmentId) => { + const manifest = getCachedRegistry()?.getHook(segmentId)?.manifest; + if (!manifest) return null; + const fileInfo = getTemplateFileInfo(segmentId); + const overlayPath = getTemplateOverlayPath(segmentId); + const hasBackup = overlayPath ? existsSync(`${overlayPath}.bak`) : false; + return { + safetyTier: manifest.safetyTier, + allowLocalOverride: !!fileInfo?.local, + disableable: manifest.disableable, + hasBackup, + }; + }, + }); + } + + // F257 Console 判据④:true-scene replay endpoint for segment observations. + { + const { segmentLifelineReplayRoutes } = await import('./routes/segment-lifeline-replay.js'); + await app.register(segmentLifelineReplayRoutes, { + traceStore: injectionTraceStore, + guardRejectionLog, + messageStore, + threadStore, + }); + } + // F257 sub-item 2: wire threshold escalation hook into GuardRejectionEventLog. // Every event append checks guard accumulation; >= 3 events in 7 days for the // same guard triggers an immediate eval:harness-ledger via handleTriggerNow. diff --git a/packages/api/src/routes/prompt-injection-overrides.ts b/packages/api/src/routes/prompt-injection-overrides.ts new file mode 100644 index 0000000000..45c21e7a63 --- /dev/null +++ b/packages/api/src/routes/prompt-injection-overrides.ts @@ -0,0 +1,217 @@ +// F257: Approval-executor minimal surface — operator-gated hook override management. +// KD-14 审批执行器 first leg: the execute path for approved segment-patch trials +// (five-ring: candidate → operator approve → THIS ROUTE → behavior diff → verify). +// +// Auth mirrors trigger-now (eval-hub.ts): session + connector-write network/owner +// gates — mutating live prompt segments is privilege-equivalent to waking eval +// cats (cloud codex R9 P1 precedent). The store's own three-axis gates +// (safetyTier / disableable / unknown-hook) stay authoritative; this route only +// transports and maps OverrideGateError to HTTP. +// +// GET list doubles as the read API for the Phase D lifeline view (KD-19). +import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'; +import { + requireConnectorWriteNetworkGuard, + requireConnectorWriteOwner, +} from '../config/connector-secret-write-guards.js'; +import type { HookOverrideStore } from '../domains/prompt-hooks/HookOverrideStore.js'; +import { OverrideGateError } from '../domains/prompt-hooks/HookOverrideStore.js'; + +export interface PromptInjectionOverrideRoutesOptions { + /** Undefined when redis is absent — routes answer 503 (observability infra off). */ + overrideStore: HookOverrideStore | undefined; +} + +const ACTIONS = ['enable', 'disable', 'rollback'] as const; +type OverrideAction = (typeof ACTIONS)[number]; + +function requireSession(request: FastifyRequest, reply: FastifyReply): string | null { + const userId = (request as FastifyRequest & { sessionUserId?: string }).sessionUserId; + if (!userId) { + reply.status(401).send({ error: 'Session required' }); + return null; + } + return userId; +} + +/** Session + connector-write network/owner gates for the mutating surface. */ +function requireWriteAuth(request: FastifyRequest, reply: FastifyReply): string | null { + const userId = requireSession(request, reply); + if (!userId) return null; + const networkError = requireConnectorWriteNetworkGuard(request); + if (networkError) { + reply.status(networkError.status).send({ error: networkError.error }); + return null; + } + const ownerError = requireConnectorWriteOwner(userId); + if (ownerError) { + reply.status(ownerError.status).send({ error: ownerError.error }); + return null; + } + return userId; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** Dispatch an approved action to the store — gates live in the store, not here. */ +async function executeOverrideAction( + store: HookOverrideStore, + action: OverrideAction, + hookId: string, + userId: string, + reason: string, +): Promise { + const actionOpts = { source: 'operator' as const, reason }; + if (action === 'enable') return store.enable(hookId, userId, actionOpts); + if (action === 'disable') return store.disable(hookId, userId, actionOpts); + return store.rollback(hookId, userId, actionOpts); +} + +/** + * Parse the untrusted request body. Non-record bodies and non-string fields + * map to 400, never 500 (terra P2: this is an operator-facing trust boundary). + */ +function parseOverrideBody(raw: unknown): { action: OverrideAction; reason: string } | { error: string } { + const body = isRecord(raw) ? raw : {}; + const action = body.action; + if (typeof action !== 'string' || !(ACTIONS as readonly string[]).includes(action)) { + return { error: `action must be one of: ${ACTIONS.join(' | ')}` }; + } + const reason = typeof body.reason === 'string' ? body.reason.trim() : ''; + if (!reason) { + return { error: 'reason is required (audit trail)' }; + } + return { action: action as OverrideAction, reason }; +} + +function parseActivateBody(raw: unknown): { epochVersion: number; reason: string } | { error: string } { + const body = isRecord(raw) ? raw : {}; + const epochVersion = typeof body.epochVersion === 'number' ? body.epochVersion : null; + if (epochVersion === null || !Number.isFinite(epochVersion) || epochVersion < 1) { + return { error: 'epochVersion (positive integer) is required' }; + } + const reason = typeof body.reason === 'string' ? body.reason.trim() : ''; + if (!reason) return { error: 'reason is required (audit trail)' }; + return { epochVersion, reason }; +} + +function parseContentBody(raw: unknown): { content: string; reason: string } | { error: string } { + const body = isRecord(raw) ? raw : {}; + const content = typeof body.content === 'string' ? body.content : null; + if (!content) return { error: 'content (string) is required' }; + const reason = typeof body.reason === 'string' ? body.reason.trim() : ''; + if (!reason) return { error: 'reason is required (audit trail)' }; + return { content, reason }; +} + +/** Map store errors to HTTP status codes. Returns null if not a known gate error. */ +function mapGateError(err: unknown, reply: FastifyReply): boolean { + if (err instanceof OverrideGateError) { + const status = err.gate === 'unknown-hook' ? 404 : 409; + reply.status(status).send({ error: err.message, gate: err.gate, hookId: err.hookId }); + return true; + } + if (err instanceof Error && err.message.includes('No content snapshot')) { + reply.status(404).send({ error: err.message }); + return true; + } + return false; +} + +export const promptInjectionOverrideRoutes: FastifyPluginAsync = async ( + app, + opts, +) => { + // Read surface: current overrides (lifeline "治理" nodes come from here + event stream). + app.get('/api/prompt-hooks/overrides', async (request, reply) => { + const userId = requireSession(request, reply); + if (!userId) return; + if (!opts.overrideStore) { + return reply.status(503).send({ error: 'override store unavailable (redis off)' }); + } + const overrides = await opts.overrideStore.listOverrides(); + return reply.send({ overrides }); + }); + + // Write surface: execute an approved action. reason is REQUIRED — every + // operator action must carry a why (audit trail feeds the lifeline view). + app.post('/api/prompt-hooks/:hookId/override', async (request, reply) => { + const userId = requireWriteAuth(request, reply); + if (!userId) return; + if (!opts.overrideStore) { + return reply.status(503).send({ error: 'override store unavailable (redis off)' }); + } + + const { hookId } = request.params as { hookId: string }; + const parsed = parseOverrideBody(request.body); + if ('error' in parsed) { + return reply.status(400).send({ error: parsed.error }); + } + + const store = opts.overrideStore; + try { + await executeOverrideAction(store, parsed.action, hookId, userId, parsed.reason); + const override = await store.getOverride(hookId); + return reply.send({ ok: true, hookId, action: parsed.action, override }); + } catch (err) { + if (!mapGateError(err, reply)) throw err; + } + }); + + // ── P1-3: Version management routes ────────────────────────── + + // List all version snapshots for a hook (epochVersion-keyed). + app.get('/api/prompt-hooks/:hookId/versions', async (request, reply) => { + const userId = requireSession(request, reply); + if (!userId) return; + if (!opts.overrideStore) { + return reply.status(503).send({ error: 'override store unavailable (redis off)' }); + } + const { hookId } = request.params as { hookId: string }; + const versions = await opts.overrideStore.listVersions(hookId); + return reply.send({ hookId, versions }); + }); + + // Activate a specific version by epochVersion. + app.post('/api/prompt-hooks/:hookId/versions/activate', async (request, reply) => { + const userId = requireWriteAuth(request, reply); + if (!userId) return; + if (!opts.overrideStore) { + return reply.status(503).send({ error: 'override store unavailable (redis off)' }); + } + const { hookId } = request.params as { hookId: string }; + const parsed = parseActivateBody(request.body); + if ('error' in parsed) return reply.status(400).send({ error: parsed.error }); + + try { + await opts.overrideStore.activateVersion(hookId, parsed.epochVersion, userId, { reason: parsed.reason }); + const override = await opts.overrideStore.getOverride(hookId); + return reply.send({ ok: true, hookId, epochVersion: parsed.epochVersion, override }); + } catch (err) { + if (!mapGateError(err, reply)) throw err; + } + }); + + // Create a new version (content override). Creates epochVersion snapshot. + app.post('/api/prompt-hooks/:hookId/versions', async (request, reply) => { + const userId = requireWriteAuth(request, reply); + if (!userId) return; + if (!opts.overrideStore) { + return reply.status(503).send({ error: 'override store unavailable (redis off)' }); + } + const { hookId } = request.params as { hookId: string }; + const parsed = parseContentBody(request.body); + if ('error' in parsed) return reply.status(400).send({ error: parsed.error }); + + try { + await opts.overrideStore.setContentOverride(hookId, parsed.content, userId, { reason: parsed.reason }); + const versions = await opts.overrideStore.listVersions(hookId); + const override = await opts.overrideStore.getOverride(hookId); + return reply.send({ ok: true, hookId, override, versions }); + } catch (err) { + if (!mapGateError(err, reply)) throw err; + } + }); +}; diff --git a/packages/api/src/routes/segment-lifeline-chain.ts b/packages/api/src/routes/segment-lifeline-chain.ts new file mode 100644 index 0000000000..4a0fd41b5e --- /dev/null +++ b/packages/api/src/routes/segment-lifeline-chain.ts @@ -0,0 +1,399 @@ +/** F257 Phase D — Version lifecycle chain builder. Pure, no Redis. */ + +import type { + ActiveStage, + EvalStageSummary, + LifecycleEvent, + OverrideChangeEvent, + VersionEpoch, + VersionEpochStatus, + VersionOrigin, +} from '@cat-cafe/shared'; +import type { CachedJudgment } from '../domains/prompt-hooks/SegmentJudgmentCache.js'; + +// Input types (pre-fetched data from stores) + +export interface SegmentObservationInput { + timestamp: number; + version: number | null; + /** + * 判据② P1 (sol R5): producer-semantics fired predicate (segment-judgment-engine + * isFired), computed at collection time from the raw trace segment. Observe-only + * rows (pipelineStatus 'observed') are observations, NOT injections. + */ + fired: boolean; +} + +export interface ChainBuilderInput { + /** Manifest baseline version (from hook.yaml). */ + manifestVersion: number; + /** Override change events filtered for this segment, sorted by timestamp. */ + overrideEvents: OverrideChangeEvent[]; + /** Observations (timestamp + version + fired) within the query window. */ + observations: SegmentObservationInput[]; + /** Eval judgment history (all judgments, oldest first). P1-2: per-version eval. */ + judgmentHistory?: CachedJudgment[]; + /** Single judgment — backward compat. Use judgmentHistory for multi-eval. */ + cachedJudgment?: CachedJudgment | null; + /** Current content version from override state. */ + currentContentVersion: number | null; +} + +// Builder + +/** + * Build the version lifecycle chain from raw data. + * + * Algorithm: + * 1. Start with manifest v1 epoch + * 2. Walk override events chronologically — content-set creates user-edit + * events and may start new version epochs + * 3. Attach observation counts to each epoch's tracing stage + * 4. Attach cached judgment to the appropriate epoch's eval stage + * 5. Derive each epoch's status from available data + */ +export function buildVersionChain(input: ChainBuilderInput): { chain: VersionEpoch[]; timeline: ActivationPoint[] } { + const { manifestVersion, overrideEvents, observations } = input; + + // Merge judgment sources: judgmentHistory (P1-2) takes precedence, cachedJudgment for compat + const allJudgments: CachedJudgment[] = input.judgmentHistory ?? (input.cachedJudgment ? [input.cachedJudgment] : []); + + // Single-pass event reducer: builds epochs AND activation timeline together. + // This avoids timestamp-based lookups that break on same-ms events (R4 P1-1). + const { epochs, timeline } = buildEpochsAndTimeline(manifestVersion, overrideEvents); + + // Attach observations using activation timeline + attachObservations(epochs, observations, timeline); + + // Attach eval judgments — each distributed to its active epoch (P1-2) + attachJudgments(epochs, allJudgments, timeline); + + // Mark active version from activation timeline (P1-3). + // Timeline's last entry = currently active epoch. Handles version-activate, + // rollback, content-clear — all encoded as timeline transitions. + markActiveFromTimeline(epochs, timeline); + + // Derive status for each epoch + for (const epoch of epochs) { + epoch.status = deriveStatus(epoch); + } + + return { chain: epochs, timeline }; +} + +// --------------------------------------------------------------------------- +// Single-pass event reducer: epochs + activation timeline (R4 fix) +// --------------------------------------------------------------------------- + +/** A point in the activation timeline: from this timestamp, epochIndex is active. */ +export interface ActivationPoint { + timestamp: number; + epochIndex: number; +} + +/** + * Single-pass event reducer: epochs + activation timeline (R4 P1-1). + * Merged to avoid timestamp-based findIndex (broke on same-ms events). + * State machine: content-set/activate → new active; rollback/clear → epoch 0; + * enable/disable → no activation change. R9: tracks activeIdx, not last-created. + */ +function buildEpochsAndTimeline( + manifestVersion: number, + events: OverrideChangeEvent[], +): { epochs: VersionEpoch[]; timeline: ActivationPoint[] } { + const epochs: VersionEpoch[] = [createEpoch(manifestVersion, 'manifest', 0)]; + const timeline: ActivationPoint[] = [{ timestamp: 0, epochIndex: 0 }]; + // R9: track active epoch (not last-created). content-set/activate/rollback/clear update it. + let activeIdx = 0; + + for (const event of events) { + const active = epochs[activeIdx]; + + if (event.action === 'content-set') { + const newVersion = event.epochVersion ?? epochs[epochs.length - 1].version + 1; // monotonic fallback + const origin: VersionOrigin = event.source === 'operator' ? 'user-create' : 'auto-iterate'; + + active.events.push({ + eventId: event.eventId, + kind: origin === 'user-create' ? 'user-create' : 'auto-iterate', + timestamp: event.timestamp, + actorId: event.actorId, + detail: `v${active.version} → v${newVersion}`, + }); + + const newEpoch = createEpoch(newVersion, origin, event.timestamp); + const newIndex = epochs.length; + epochs.push(newEpoch); + activeIdx = newIndex; + timeline.push({ timestamp: event.timestamp, epochIndex: newIndex }); + } else if (event.action === 'rollback' || event.action === 'content-clear') { + active.events.push({ + eventId: event.eventId, + kind: 'version-activate', + timestamp: event.timestamp, + actorId: event.actorId, + detail: + event.action === 'rollback' + ? `rolled back to v${manifestVersion}` + : `content cleared, reverted to v${manifestVersion}`, + }); + activeIdx = 0; + timeline.push({ timestamp: event.timestamp, epochIndex: 0 }); + } else if (event.action === 'version-activate') { + const targetVersion = event.epochVersion ?? event.contentVersion; + if (targetVersion != null) { + const targetIdx = epochs.findIndex((e) => e.version === targetVersion); + if (targetIdx >= 0) { + active.events.push({ + eventId: event.eventId, + kind: 'version-activate', + timestamp: event.timestamp, + actorId: event.actorId, + detail: `activated v${targetVersion}`, + }); + activeIdx = targetIdx; + timeline.push({ timestamp: event.timestamp, epochIndex: targetIdx }); + } + } + } else if (event.action === 'enable' || event.action === 'disable') { + // AF-5: distinguish operator governance vs auto-eval actions by event.source + const kind: LifecycleEvent['kind'] = + event.source === 'operator' + ? event.action === 'enable' + ? 'governance-approve' + : 'governance-reject' + : event.action === 'enable' + ? 'eval-pass' + : 'eval-reject'; + active.events.push({ + eventId: event.eventId, + kind, + timestamp: event.timestamp, + actorId: event.actorId, + detail: event.action === 'enable' ? 'enabled' : 'disabled', + }); + } + } + + return { epochs, timeline }; +} + +function createEpoch(version: number, origin: VersionOrigin, startedAt: number): VersionEpoch { + return { + version, + origin, + startedAt, + status: 'idle', + isActive: false, + tracing: null, + eval: null, + governance: null, + events: [], + }; +} + +/** Resolve which epoch was active at a given timestamp using the activation timeline. */ +export function resolveActiveEpochAt( + timeline: ActivationPoint[], + timestamp: number, + epochs: VersionEpoch[], +): VersionEpoch { + let idx = 0; + for (const point of timeline) { + if (point.timestamp <= timestamp) { + idx = point.epochIndex; + } else { + break; + } + } + return epochs[idx] ?? epochs[0]; +} + +/** Attribute guard events to epochs using the activation timeline (R15). */ +export function attributeGuardEventsToEpochs( + chain: VersionEpoch[], + timeline: ActivationPoint[], + guardEvents: Array<{ timestamp: number; guardId: string }>, +): Record> { + const counts = new Map>(); + for (const e of chain) counts.set(e.version, new Map()); + for (const ge of guardEvents) { + const epoch = resolveActiveEpochAt(timeline, ge.timestamp, chain); + const m = counts.get(epoch.version); + if (m) m.set(ge.guardId, (m.get(ge.guardId) ?? 0) + 1); + } + const result: Record> = {}; + for (const [ver, m] of counts) { + result[ver] = [...m].map(([guardId, count]) => ({ guardId, count })).sort((a, b) => b.count - a.count); + } + return result; +} + +// Observation attachment + +function attachObservations( + epochs: VersionEpoch[], + observations: SegmentObservationInput[], + timeline: ActivationPoint[], +): void { + if (observations.length === 0) return; + + for (const obs of observations) { + const epoch = resolveActiveEpochAt(timeline, obs.timestamp, epochs); + + if (!epoch.tracing) { + epoch.tracing = { observationCount: 0, firedCount: 0, firstAt: null, lastAt: null }; + } + + epoch.tracing.observationCount++; + if (obs.fired) epoch.tracing.firedCount++; + if (epoch.tracing.firstAt === null || obs.timestamp < epoch.tracing.firstAt) { + epoch.tracing.firstAt = obs.timestamp; + } + if (epoch.tracing.lastAt === null || obs.timestamp > epoch.tracing.lastAt) { + epoch.tracing.lastAt = obs.timestamp; + } + } +} + +// Active epoch marking + +/** + * Mark the active epoch from the activation timeline (P1-3). + * + * The last entry in the timeline determines which epoch is currently active. + * This naturally handles all activation transitions: content-set, rollback, + * content-clear, and version-activate — all encoded as timeline entries. + */ +function markActiveFromTimeline(epochs: VersionEpoch[], timeline: ActivationPoint[]): void { + if (epochs.length === 0 || timeline.length === 0) return; + const lastPoint = timeline[timeline.length - 1]; + const activeIdx = lastPoint.epochIndex; + if (activeIdx >= 0 && activeIdx < epochs.length) { + epochs[activeIdx].isActive = true; + } +} + +// --------------------------------------------------------------------------- +// Judgment attachment +// --------------------------------------------------------------------------- + +/** + * Project a CachedJudgment into the epoch eval stage summary (判据②). + * + * Propagates the judgment's OWN eval window + denominator — the query window + * must never substitute for them; legacy entries carry explicit null + * (fail-visible, normalized at the cache read seam). + */ +function toEvalStageSummary(judgment: CachedJudgment): EvalStageSummary { + return { + verdict: judgment.verdict, + injectionCount: judgment.injectionCount, + violationCount: judgment.violationCount, + evaluatedAt: judgment.evaluatedAt, + evalWindow: judgment.window ?? null, + // P2 (sol R5): preserve the gap KIND — corrupted provenance must not be + // mislabeled as a legacy missing field. Hand-built judgments without the + // gap fields degrade to 'legacy-missing' (absent = legacy by definition). + evalWindowGap: judgment.window ? null : (judgment.windowGap ?? 'legacy-missing'), + denominatorKind: judgment.denominatorKind ?? null, + denominatorGap: judgment.denominatorKind ? null : (judgment.denominatorGap ?? 'legacy-missing'), + }; +} + +/** + * Attach judgment history to epochs (R8: version-aware attribution). + * segmentVersion (R7+) → direct epoch match; null → activation timeline fallback. + * Latest-wins per epoch. Governance derivation on the winning judgment. + */ +function attachJudgments(epochs: VersionEpoch[], judgments: CachedJudgment[], timeline: ActivationPoint[]): void { + if (epochs.length === 0 || judgments.length === 0) return; + + for (const judgment of judgments) { + // R8: prefer direct version match (epochVersion is the truth source). + // Only fall back to activation timeline for legacy judgments without version. + let target: VersionEpoch | undefined; + if (judgment.segmentVersion != null) { + target = epochs.find((e) => e.version === judgment.segmentVersion); + } + if (!target) { + target = resolveActiveEpochAt(timeline, judgment.evaluatedAt, epochs); + } + const existing = target.eval; + + // Latest-wins: only overwrite if this judgment is newer + if (existing && existing.evaluatedAt !== null && existing.evaluatedAt >= judgment.evaluatedAt) { + continue; + } + + target.eval = toEvalStageSummary(judgment); + + // Governance derivation from the winning judgment + if (judgment.verdict === 'alive' || judgment.verdict === 'dormant') { + target.governance = { decision: 'pending', decidedAt: null, actorId: null }; + } else { + // Clear governance if newer judgment doesn't warrant it + target.governance = null; + } + } +} + +// --------------------------------------------------------------------------- +// Status derivation +// --------------------------------------------------------------------------- + +function deriveStatus(epoch: VersionEpoch): VersionEpochStatus { + // Check governance first (most advanced stage) + if (epoch.governance?.decision === 'approved') return 'governance-approved'; + if (epoch.governance?.decision === 'pending') return 'governance-pending'; + + // Check eval + if (epoch.eval) { + if (epoch.eval.verdict === 'alive') return 'eval-pass'; + if (epoch.eval.verdict === 'dormant' || epoch.eval.verdict === 'retire-candidate') { + return 'eval-reject'; + } + return 'eval-pending'; + } + + // Check tracing + if (epoch.tracing && epoch.tracing.observationCount > 0) return 'tracing'; + + return 'idle'; +} + +// --------------------------------------------------------------------------- +// 判据① — activeStage: the loop's REAL stage (F257 #6 slice 6b) +// --------------------------------------------------------------------------- + +/** + * Derive the real stage of the lifecycle loop for the given (active) epoch. + * + * Loop model, not one-way pipeline: an eval that cannot conclude + * (`unmeasurable` / `observability-debt` / `needs-denominator`) or rejects + * (`retire-candidate`) returns the cycle to `tracing` — the lifeline must NOT + * paint the cycle as stopped at eval/governance. Only a conclusive + * `alive` / `dormant` verdict parks the cycle at `governance` (informational). + * + * Note: `governance.decision === 'pending'` is deliberately NOT an input here — + * it is synthesized from alive/dormant and must never be read as + * "operator action needed" (the original incident's false signal). + */ +export function deriveActiveStage(epoch: VersionEpoch | undefined): ActiveStage { + if (!epoch) return 'tracing'; + const verdict = epoch.eval?.verdict; + return verdict === 'alive' || verdict === 'dormant' ? 'governance' : 'tracing'; +} + +// --------------------------------------------------------------------------- +// Backward-compat status +// --------------------------------------------------------------------------- + +/** Derive the legacy status field from the chain. */ +export function deriveCurrentStatus(chain: VersionEpoch[]): 'idle' | 'tracing' | 'evaluated' { + const active = chain.find((e) => e.isActive) ?? chain[chain.length - 1]; + if (!active) return 'idle'; + if (active.eval) return 'evaluated'; + if (active.tracing && active.tracing.observationCount > 0) return 'tracing'; + return 'idle'; +} diff --git a/packages/api/src/routes/segment-lifeline-replay.ts b/packages/api/src/routes/segment-lifeline-replay.ts new file mode 100644 index 0000000000..5c0d79a3d2 --- /dev/null +++ b/packages/api/src/routes/segment-lifeline-replay.ts @@ -0,0 +1,321 @@ +/** + * F257 Console 判据④ — Segment lifeline true-scene replay endpoint. + * + * Returns the event-time rendered segment content, source provenance, + * variable bindings, nearby guard events, and captured conversation context + * for a single (segmentId, threadId, turnId) observation. + * + * Auth: session-only (read surface, no mutation). Thread ownership is verified + * via threadStore; cross-user access is rejected. + * + * Truth source: ReplaySnapshot (durable, owner-scoped, TTL=0). The compact + * InjectionTraceSummary/detail is NOT the replay source; missing snapshots are + * surfaced as a structured provenance gap rather than silently degrading to + * current-state reconstruction. + */ + +import type { + ReplayProvenanceGap, + ReplaySnapshot, + ReplaySurroundingMessage, + SegmentReplayResponse, +} from '@cat-cafe/shared'; +import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'; +import type { IMessageStore, StoredMessage } from '../domains/cats/services/stores/ports/MessageStore.js'; +import type { IThreadStore } from '../domains/cats/services/stores/ports/ThreadStore.js'; +import type { InjectionTraceStore } from '../domains/prompt-hooks/InjectionTraceStore.js'; +import type { + GuardRejectionEvent, + GuardRejectionEventLog, +} from '../infrastructure/harness-eval/GuardRejectionEventLog.js'; + +export interface SegmentLifelineReplayRoutesOptions { + traceStore?: InjectionTraceStore; + guardRejectionLog?: GuardRejectionEventLog; + /** Message store for surrounding conversation context. Absence = unavailable gap. */ + messageStore?: IMessageStore; + /** Thread store for ownership authorization. Absence = 503. */ + threadStore?: IThreadStore; +} + +const REPLAY_GUARD_WINDOW_MS = 120_000; +const PREVIEW_MAX_LEN = 200; + +function requireSession(request: FastifyRequest, reply: FastifyReply): string | null { + const userId = (request as FastifyRequest & { sessionUserId?: string }).sessionUserId; + if (!userId) { + reply.status(401).send({ error: 'Session required' }); + return null; + } + return userId; +} + +async function requireThreadAccess( + threadStore: IThreadStore | undefined, + threadId: string, + userId: string, + reply: FastifyReply, +): Promise { + if (!threadStore) { + reply.status(503).send({ error: 'Thread store unavailable' }); + return false; + } + try { + const thread = await threadStore.get(threadId); + if (!thread) { + reply.status(404).send({ error: 'Thread not found' }); + return false; + } + if (thread.createdBy !== userId) { + reply.status(403).send({ error: 'Access denied' }); + return false; + } + return true; + } catch { + reply.status(503).send({ error: 'Thread access check failed' }); + return false; + } +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function validateTemplateVars(raw: unknown): { vars: Record | null; gap: ReplayProvenanceGap | null } { + if (raw === undefined) return { vars: null, gap: 'legacy-missing' }; + // F257 R2: null templateVars is valid for source kinds that do not use variables + // (e.g. native-l0, content-var, override). Treat as "not applicable" rather than corrupt. + if (raw === null) return { vars: null, gap: null }; + if (!isPlainObject(raw)) return { vars: null, gap: 'invalid-present' }; + for (const [key, value] of Object.entries(raw)) { + if (typeof key !== 'string' || typeof value !== 'string') { + return { vars: null, gap: 'invalid-present' }; + } + } + return { vars: raw as Record, gap: null }; +} + +function validateVersion(raw: unknown): { version: number | null; gap: ReplayProvenanceGap | null } { + if (raw === undefined || raw === null) return { version: null, gap: 'legacy-missing' }; + if (typeof raw !== 'number' || !Number.isInteger(raw) || raw <= 0) return { version: null, gap: 'invalid-present' }; + return { version: raw, gap: null }; +} + +function validateStringField(raw: unknown): { value: string | null; gap: ReplayProvenanceGap | null } { + if (raw === undefined) return { value: null, gap: 'legacy-missing' }; + if (raw === null) return { value: null, gap: 'invalid-present' }; + if (typeof raw !== 'string') return { value: null, gap: 'invalid-present' }; + return { value: raw, gap: null }; +} + +function validateSourceKind(raw: unknown): { + value: SegmentReplayResponse['contentSourceKind']; + gap: ReplayProvenanceGap | null; +} { + if (raw === undefined) return { value: null, gap: 'legacy-missing' }; + if (raw === null) return { value: null, gap: null }; + const valid = ['template', 'override', 'content-var', 'file-fallback', 'native-l0', 'aggregate'] as const; + if (!valid.includes(raw as (typeof valid)[number])) return { value: null, gap: 'invalid-present' }; + return { value: raw as SegmentReplayResponse['contentSourceKind'], gap: null }; +} + +function validateMessageAnchorId(raw: unknown): { value: string | null; gap: ReplayProvenanceGap | null } { + if (raw === undefined || raw === null) return { value: null, gap: 'legacy-missing' }; + if (typeof raw !== 'string' || raw.length === 0) return { value: null, gap: 'invalid-present' }; + return { value: raw, gap: null }; +} + +function validateSurroundingMessageIds(raw: unknown): { value: string[] | null; gap: ReplayProvenanceGap | null } { + if (raw === undefined) return { value: null, gap: 'legacy-missing' }; + if (raw === null) return { value: null, gap: 'invalid-present' }; + if (!Array.isArray(raw)) return { value: null, gap: 'invalid-present' }; + if (!raw.every((id) => typeof id === 'string' && id.length > 0)) return { value: null, gap: 'invalid-present' }; + return { value: raw as string[], gap: null }; +} + +function validateSurroundingMessagesGap(raw: unknown): { + value: ReplayProvenanceGap | null; + gap: ReplayProvenanceGap | null; +} { + if (raw === undefined) return { value: null, gap: 'legacy-missing' }; + const valid: Array = [null, 'unavailable', 'legacy-missing', 'invalid-present']; + if (!valid.includes(raw as ReplayProvenanceGap | null)) return { value: null, gap: 'invalid-present' }; + return { value: raw as ReplayProvenanceGap | null, gap: null }; +} + +function mapGuardEvent(event: GuardRejectionEvent): SegmentReplayResponse['guardEvents'][number] { + return { + eventId: event.eventId, + kind: event.kind, + guardId: event.guardId, + catId: event.catId, + timestamp: event.timestamp, + attribution: 'window-correlated', + }; +} + +function deriveMessageRole(msg: StoredMessage): ReplaySurroundingMessage['role'] { + const author = msg.provenance?.author; + if (author === 'system') return 'system'; + if (author === 'cat' || msg.catId != null) return 'assistant'; + return 'user'; +} + +function mapSurroundingMessage(msg: StoredMessage): ReplaySurroundingMessage { + const preview = msg.content?.slice(0, PREVIEW_MAX_LEN) ?? ''; + const ellipsis = msg.content && msg.content.length > PREVIEW_MAX_LEN ? '…' : ''; + return { + messageId: msg.id, + role: deriveMessageRole(msg), + catId: msg.catId, + contentPreview: `${preview}${ellipsis}`, + timestamp: msg.timestamp, + }; +} + +async function resolveSurroundingMessages( + snapshot: ReplaySnapshot, + messageStore: IMessageStore | undefined, + threadId: string, + userId: string, +): Promise<{ messages: ReplaySurroundingMessage[] | null; gap: ReplayProvenanceGap | null }> { + const idsValidation = validateSurroundingMessageIds(snapshot.surroundingMessageIds); + const gapValidation = validateSurroundingMessagesGap(snapshot.surroundingMessagesGap); + + if (gapValidation.gap !== null) { + // The gap field itself is absent or malformed (legacy-missing / invalid-present). + return { messages: null, gap: gapValidation.gap }; + } + if (gapValidation.value !== null) { + // Valid stored completeness gap (e.g. unavailable) — do not reconstruct context. + return { messages: null, gap: gapValidation.value }; + } + if (idsValidation.gap !== null) { + return { messages: null, gap: idsValidation.gap }; + } + return fetchSurroundingMessages(messageStore, idsValidation.value, threadId, userId); +} + +async function fetchGuardEvents( + log: GuardRejectionEventLog | undefined, + threadId: string, + catId: string, + timestamp: number, +): Promise<{ events: SegmentReplayResponse['guardEvents']; gap: ReplayProvenanceGap | null }> { + if (!log) return { events: [], gap: 'unavailable' }; + try { + const events = await log.queryWindow({ + since: timestamp - REPLAY_GUARD_WINDOW_MS, + until: timestamp + REPLAY_GUARD_WINDOW_MS, + threadId, + catId, + limit: 50, + }); + return { events: events.map(mapGuardEvent), gap: null }; + } catch { + return { events: [], gap: 'unavailable' }; + } +} + +function isMessageVisible(msg: StoredMessage, threadId: string, userId: string): boolean { + if (msg._tombstone || msg.deletedAt != null) return false; + if (msg.threadId !== threadId) return false; + // Owner scope: same user, or system messages that are not user-scoped. + if (msg.userId !== userId && msg.provenance?.author !== 'system') return false; + return true; +} + +async function fetchSurroundingMessages( + store: IMessageStore | undefined, + snapshotIds: string[] | null, + threadId: string, + userId: string, +): Promise<{ messages: ReplaySurroundingMessage[] | null; gap: ReplayProvenanceGap | null }> { + if (!store) return { messages: null, gap: 'unavailable' }; + if (!snapshotIds || snapshotIds.length === 0) return { messages: [], gap: null }; + try { + const messages = await store.getByIds(snapshotIds); + const byId = new Map(messages.map((m) => [m.id, m])); + // Preserve snapshot order; drop missing/deleted/cross-thread messages without failing. + const ordered = snapshotIds + .map((id) => byId.get(id)) + .filter((m): m is StoredMessage => m !== undefined && isMessageVisible(m, threadId, userId)); + // If any expected message is missing/deleted/invisible, the event-time context is incomplete. + if (ordered.length < snapshotIds.length) { + return { messages: ordered.map(mapSurroundingMessage), gap: 'unavailable' }; + } + return { messages: ordered.map(mapSurroundingMessage), gap: null }; + } catch { + return { messages: null, gap: 'unavailable' }; + } +} + +export const segmentLifelineReplayRoutes: FastifyPluginAsync = async ( + app, + opts, +) => { + app.get('/api/segment-lifeline/:segmentId/replay', async (request, reply) => { + const userId = requireSession(request, reply); + if (!userId) return; + + if (!opts.traceStore) { + return reply.status(503).send({ error: 'Trace store unavailable (redis off)' }); + } + + const { segmentId } = request.params as { segmentId: string }; + const query = request.query as { threadId?: string; turnId?: string }; + const { threadId, turnId } = query; + if (!threadId || !turnId) { + return reply.status(400).send({ error: 'threadId and turnId are required' }); + } + + const hasAccess = await requireThreadAccess(opts.threadStore, threadId, userId, reply); + if (!hasAccess) return; + + const snapshot = await opts.traceStore.getReplaySnapshot(threadId, turnId, segmentId); + if (!snapshot) { + return reply.status(404).send({ error: 'Replay snapshot not found' }); + } + if (snapshot.ownerUserId !== userId) { + return reply.status(403).send({ error: 'Access denied' }); + } + + const contentValidation = validateStringField(snapshot.content); + const sourceKindValidation = validateSourceKind(snapshot.contentSourceKind); + const templateRefValidation = validateStringField(snapshot.contentSourceRef); + const templateVarsValidation = validateTemplateVars(snapshot.templateVars); + const versionValidation = validateVersion(snapshot.version); + const anchorValidation = validateMessageAnchorId(snapshot.messageAnchorId); + + const guardResult = await fetchGuardEvents(opts.guardRejectionLog, threadId, snapshot.catId, snapshot.timestamp); + const messagesResult = await resolveSurroundingMessages(snapshot, opts.messageStore, threadId, userId); + + const response: SegmentReplayResponse = { + segmentId, + threadId, + turnId, + timestamp: snapshot.timestamp, + catId: snapshot.catId, + stage: snapshot.stage, + pipelineStatus: snapshot.pipelineStatus, + version: versionValidation.version, + versionGap: versionValidation.gap, + content: contentValidation.value, + contentGap: contentValidation.gap, + contentSourceKind: sourceKindValidation.value, + contentSourceKindGap: sourceKindValidation.gap, + templateRef: templateRefValidation.value, + templateRefGap: templateRefValidation.gap, + templateVars: templateVarsValidation.vars, + templateVarsGap: templateVarsValidation.gap, + messageAnchorId: anchorValidation.value, + messageAnchorIdGap: anchorValidation.gap, + surroundingMessages: messagesResult.messages, + surroundingMessagesGap: messagesResult.gap, + guardEvents: guardResult.events, + guardEventsGap: guardResult.gap, + }; + + return reply.send(response); + }); +}; diff --git a/packages/api/src/routes/segment-lifeline.ts b/packages/api/src/routes/segment-lifeline.ts new file mode 100644 index 0000000000..abc4e9f4b5 --- /dev/null +++ b/packages/api/src/routes/segment-lifeline.ts @@ -0,0 +1,427 @@ +/** + * F257 Phase D — Segment lifeline endpoint. + * + * Read-model join: InjectionTraceStore + GuardRejectionEventLog + HookOverrideStore + * + SegmentJudgmentCache → version lifecycle chain response. + * + * Zero new data collection — pure join of existing stores. + * Auth: session-only (read surface, no mutation). + */ +import type { ActionableInfo, SafetyTier, SegmentEnablementMatrix, SegmentLifecycleResponse } from '@cat-cafe/shared'; +import { resolveSegmentEnablementMatrix } from '@cat-cafe/shared'; +import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'; +import type { IMessageStore } from '../domains/cats/services/stores/ports/MessageStore.js'; +import type { ThreadStore } from '../domains/cats/services/stores/ports/ThreadStore.js'; +import type { HookOverrideStore } from '../domains/prompt-hooks/HookOverrideStore.js'; +import type { InjectionTraceStore } from '../domains/prompt-hooks/InjectionTraceStore.js'; +import type { SegmentJudgmentCache } from '../domains/prompt-hooks/SegmentJudgmentCache.js'; +import type { GuardRejectionEventLog } from '../infrastructure/harness-eval/GuardRejectionEventLog.js'; +import { isFired } from '../infrastructure/harness-eval/segment-judgment-engine.js'; +import { + attributeGuardEventsToEpochs, + buildVersionChain, + deriveActiveStage, + deriveCurrentStatus, + type SegmentObservationInput, +} from './segment-lifeline-chain.js'; + +export interface SegmentLifelineRoutesOptions { + traceStore?: InjectionTraceStore; + guardRejectionLog?: GuardRejectionEventLog; + overrideStore?: HookOverrideStore; + judgmentCache?: SegmentJudgmentCache; + /** + * F257 Console 判据④:message store for replaying the surrounding conversation + * context at event time. Optional — absence degrades to unavailable gap. + */ + messageStore?: IMessageStore; + /** + * F257 Console 判据④:thread store for ownership authorization on replay. + * Required — absence returns 503. + */ + threadStore?: ThreadStore; + /** Resolve manifest version for a segmentId. Returns 1 if unknown. */ + resolveManifestVersion?: (segmentId: string) => number; + /** Resolve segment name from manifest. Returns segmentId if unknown. */ + resolveSegmentName?: (segmentId: string) => string; + /** + * F257 Console 判据⑥: resolve segment manifest constraints + backup state + * needed to build the enablement matrix. Null when segment is unknown. + */ + resolveSegmentManifest?: (segmentId: string) => { + safetyTier: SafetyTier; + allowLocalOverride: boolean; + disableable: boolean; + hasBackup: boolean; + } | null; + /** + * 判据①: resolve the REAL pending governance Candidate count for a segment. + * Return null when the Candidate projection is unavailable — the response + * then honestly reports source:'unavailable' instead of guessing from the + * synthesized governance.pending (the original incident's false signal). + * When this option itself is absent, the projection is not wired → unavailable. + */ + resolvePendingCandidateCount?: (segmentId: string) => Promise; +} + +const DEFAULT_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; // 7 days +const MAX_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; // 30 days cap +/** + * Cap on DETAIL rows only (sol R6 P1). Aggregate per-epoch counts + * (observationCount/firedCount) are computed from a full-window scan and are + * always exact — the cap must never turn an unsampled epoch into tracing:null + * or present a truncated count as a total. + */ +const MAX_OBSERVATIONS = 100; + +function requireSession(request: FastifyRequest, reply: FastifyReply): string | null { + const userId = (request as FastifyRequest & { sessionUserId?: string }).sessionUserId; + if (!userId) { + reply.status(401).send({ error: 'Session required' }); + return null; + } + return userId; +} + +/** Parse and validate windowMs query param. Returns null on invalid input. */ +function parseWindowMs(raw: string | undefined): number | null { + if (raw === undefined) return DEFAULT_WINDOW_MS; + const n = Number(raw); + if (!Number.isFinite(n) || n <= 0) return null; + return Math.min(n, MAX_WINDOW_MS); +} + +export const segmentLifelineRoutes: FastifyPluginAsync = async (app, opts) => { + app.get('/api/segment-lifeline/:segmentId', async (request, reply) => { + const userId = requireSession(request, reply); + if (!userId) return; + + if (!opts.traceStore) { + return reply.status(503).send({ error: 'Trace store unavailable (redis off)' }); + } + + const { segmentId } = request.params as { segmentId: string }; + const query = request.query as { windowMs?: string }; + const windowMs = parseWindowMs(query.windowMs); + if (windowMs === null) { + return reply.status(400).send({ error: 'windowMs must be a finite positive number' }); + } + const now = Date.now(); + const windowStart = now - windowMs; + const windowEnd = now; + + const data = await assembleLifelineData(opts.traceStore, opts, segmentId, windowStart, windowEnd); + const actionable = await resolveActionableInfo(segmentId, opts.resolvePendingCandidateCount, request.log); + + const response = { + segmentId, + segmentName: data.segmentName, + activeVersion: data.activeEpoch?.version ?? data.manifestVersion, + chain: data.chain, + currentStatus: deriveCurrentStatus(data.chain), + activeStage: deriveActiveStage(data.activeEpoch), + actionable, + window: { startMs: windowStart, endMs: windowEnd }, + // Retained for backward compat + detail views + observations: data.observations, + // P1 (sol R6): completeness provenance for the DETAIL list alone — true + // when more matching rows existed than MAX_OBSERVATIONS. Aggregate + // counts are exact regardless (full-window scan). + observationsCapped: data.observationsCapped, + guardEvents: data.guardEvents, + overrideState: data.overrideState + ? { hookId: segmentId, enabled: data.overrideState.enabled, contentVersion: data.overrideState.contentVersion } + : null, + epochGuardMetrics: data.epochGuardMetrics, + enablementMatrix: data.enablementMatrix, + } satisfies SegmentLifecycleResponse; + + return reply.send(response); + }); +}; + +// ── Read-model assembly ────────────────────────────────────── + +interface LifelineData { + segmentName: string; + manifestVersion: number; + chain: import('@cat-cafe/shared').VersionEpoch[]; + activeEpoch: import('@cat-cafe/shared').VersionEpoch | undefined; + observations: SegmentObservation[]; + /** True when detail rows were dropped by MAX_OBSERVATIONS (counts stay exact). */ + observationsCapped: boolean; + guardEvents: Array<{ + eventId: string; + kind: string; + threadId: string; + catId: string; + timestamp: number; + guardId: string; + attribution: 'window-correlated'; + }>; + overrideState: { enabled: boolean; contentVersion: number | null } | null; + epochGuardMetrics: Record; + enablementMatrix: SegmentEnablementMatrix; +} + +/** Join trace/override/judgment/guard stores into the lifecycle chain (steps 1-8). */ +async function assembleLifelineData( + traceStore: InjectionTraceStore, + opts: SegmentLifelineRoutesOptions, + segmentId: string, + windowStart: number, + windowEnd: number, +): Promise { + // 1. Collect raw observations (full-window scan; detail list capped) + const { observations, observationInputs, detailCapped } = await collectObservations( + traceStore, + segmentId, + windowStart, + windowEnd, + ); + + // 2. Collect override events for this segment + const overrideEvents = opts.overrideStore ? await collectSegmentOverrideEvents(opts.overrideStore, segmentId) : []; + + // 3. Get current override state for contentVersion + const overrideState = opts.overrideStore ? await getOverrideState(opts.overrideStore, segmentId) : null; + + // 4. Get judgment history (P1-2: per-version eval) + const judgmentHistory = opts.judgmentCache ? await opts.judgmentCache.getHistory(segmentId) : []; + + // 5. Resolve manifest version + const manifestVersion = opts.resolveManifestVersion?.(segmentId) ?? 1; + const segmentName = opts.resolveSegmentName?.(segmentId) ?? segmentId; + + // 6. Build version lifecycle chain (R15: returns timeline for guard attribution) + const { chain, timeline } = buildVersionChain({ + manifestVersion, + overrideEvents, + observations: observationInputs, + judgmentHistory, + currentContentVersion: overrideState?.contentVersion ?? null, + }); + + // 7. Guard events — still collected for detail view + const guardEvents = opts.guardRejectionLog + ? await collectGuardEvents(opts.guardRejectionLog, windowStart, windowEnd, observations) + : []; + + // 8. Attribute guard events to epochs using activation timeline (R15 P1) + const epochGuardMetrics = attributeGuardEventsToEpochs(chain, timeline, guardEvents); + + const enablementMatrix = await buildLifelineEnablementMatrix(segmentId, opts, overrideState); + + return { + segmentName, + manifestVersion, + chain, + activeEpoch: chain.find((e) => e.isActive) ?? chain[chain.length - 1], + observations, + observationsCapped: detailCapped, + guardEvents, + overrideState, + epochGuardMetrics, + enablementMatrix, + }; +} + +async function buildLifelineEnablementMatrix( + segmentId: string, + opts: SegmentLifelineRoutesOptions, + overrideState: { enabled: boolean; contentVersion: number | null } | null, +): Promise { + const manifestInfo = opts.resolveSegmentManifest?.(segmentId); + const enabled = overrideState?.enabled ?? true; + const hasOverride = overrideState !== null; + const hasContentOverride = (overrideState?.contentVersion ?? null) !== null; + + let hasVersionSnapshot = false; + const availableEpochVersions: number[] = []; + if (opts.overrideStore && typeof opts.overrideStore.listVersions === 'function') { + const versions = await opts.overrideStore.listVersions(segmentId); + if (versions.length > 0) { + hasVersionSnapshot = true; + for (const v of versions) availableEpochVersions.push(v.version); + } + } + + return resolveSegmentEnablementMatrix({ + segmentId, + safetyTier: manifestInfo?.safetyTier ?? 'readonly', + allowLocalOverride: manifestInfo?.allowLocalOverride ?? false, + disableable: manifestInfo?.disableable ?? false, + localOverlay: { hasOverlay: false, hasBackup: manifestInfo?.hasBackup ?? false }, + runtimeOverride: { + enabled, + hasOverride, + hasContentOverride, + hasVersionSnapshot, + availableEpochVersions, + }, + }); +} + +/** + * 判据①: resolve actionable info from the REAL pending Candidate count — fail-safe. + * + * The Candidate projection is the ONLY authority for actionability; the + * synthesized governance.pending is never consulted. Fail-closed to the + * honest provenance gap: provider absent / throwing / returning an invalid + * count (non-integer, negative, NaN) → source:'unavailable' with a + * server-side warning, NEVER a guessed count (P2-3). + */ +async function resolveActionableInfo( + segmentId: string, + provider: ((segmentId: string) => Promise) | undefined, + log: { warn: (obj: object, msg: string) => void }, +): Promise { + const unavailable: ActionableInfo = { stage: null, candidateCount: null, source: 'unavailable' }; + if (!provider) return unavailable; + + let count: number | null; + try { + count = await provider(segmentId); + } catch (err) { + log.warn({ err, segmentId }, 'candidate-count provider threw; degrading to unavailable'); + return unavailable; + } + + if (count == null) return unavailable; + if (!Number.isInteger(count) || count < 0) { + log.warn({ segmentId, count }, 'candidate-count provider returned invalid count; degrading to unavailable'); + return unavailable; + } + return { stage: count > 0 ? 'governance' : null, candidateCount: count, source: 'candidate-count' }; +} + +// ── Data collection helpers ────────────────────────────────── + +interface SegmentObservation { + threadId: string; + turnId: string; + timestamp: number; + catId: string; + pipelineStatus: string; + version: number | null; + charCount: number; +} + +/** + * Collect observations for the segment within the window (sol R6 P1). + * + * Aggregate counting is a FULL-WINDOW scan — every matching row contributes + * to observationInputs (exact per-epoch counts downstream). Only the DETAIL + * row list is capped: the MAX_OBSERVATIONS most recent rows, with + * `detailCapped` completeness provenance when rows were dropped. + */ +async function collectObservations( + store: InjectionTraceStore, + segmentId: string, + startMs: number, + endMs: number, +): Promise<{ + observations: SegmentObservation[]; + observationInputs: SegmentObservationInput[]; + detailCapped: boolean; +}> { + const threadIds = await store.listTracedThreadIds(); + const allRows: SegmentObservation[] = []; + const observationInputs: SegmentObservationInput[] = []; + + for (const threadId of threadIds) { + const summaries = await store.queryWindow(threadId, startMs, endMs); + for (const summary of summaries) { + const seg = summary.segments.find((s) => s.segmentId === segmentId && s.status === 'observed'); + if (!seg) continue; + allRows.push({ + threadId: summary.threadId, + turnId: summary.turnId, + timestamp: summary.timestamp, + catId: summary.catId, + pipelineStatus: seg.pipelineStatus ?? 'observed', + version: seg.version ?? null, + charCount: seg.charCount, + }); + observationInputs.push({ + timestamp: summary.timestamp, + version: seg.version ?? null, + // P1: producer-semantics fired predicate — single source of truth is + // segment-judgment-engine isFired (observe-only ≠ injection). + fired: isFired(seg), + }); + } + } + + allRows.sort((a, b) => b.timestamp - a.timestamp); + return { + observations: allRows.slice(0, MAX_OBSERVATIONS), + observationInputs, + detailCapped: allRows.length > MAX_OBSERVATIONS, + }; +} + +/** ±120s proximity window for guard event attribution. */ +const GUARD_PROXIMITY_MS = 120_000; + +async function collectGuardEvents( + log: GuardRejectionEventLog, + startMs: number, + endMs: number, + observations: SegmentObservation[], +): Promise< + Array<{ + eventId: string; + kind: string; + threadId: string; + catId: string; + timestamp: number; + guardId: string; + attribution: 'window-correlated'; + }> +> { + if (observations.length === 0) return []; + const events = await log.queryWindow({ since: startMs, until: endMs, limit: 50 }); + return events + .filter((e) => + observations.some( + (obs) => + obs.threadId === e.threadId && + obs.catId === e.catId && + Math.abs(obs.timestamp - e.timestamp) <= GUARD_PROXIMITY_MS, + ), + ) + .map((e) => ({ + eventId: e.eventId, + kind: e.kind, + threadId: e.threadId, + catId: e.catId, + timestamp: e.timestamp, + guardId: e.guardId, + attribution: 'window-correlated' as const, + })); +} + +async function collectSegmentOverrideEvents( + store: HookOverrideStore, + segmentId: string, +): Promise { + // Chain needs full history for this segment. + // HookOverrideStore.listEvents() has no hookId filter — fetch all and filter. + // Ceiling of 10000 covers any realistic lifetime event count. + const allEvents = await store.listEvents({ limit: 10000 }); + return allEvents.filter((e) => e.hookId === segmentId); +} + +async function getOverrideState( + store: HookOverrideStore, + segmentId: string, +): Promise<{ enabled: boolean; contentVersion: number | null } | null> { + const overrides = await store.listOverrides(); + const match = overrides.find((o) => o.hookId === segmentId); + if (!match) return null; + return { + enabled: match.enabled !== false, + contentVersion: match.contentVersion ?? null, + }; +} diff --git a/packages/api/test/prompt-injection-overrides.test.js b/packages/api/test/prompt-injection-overrides.test.js new file mode 100644 index 0000000000..9a733ff408 --- /dev/null +++ b/packages/api/test/prompt-injection-overrides.test.js @@ -0,0 +1,252 @@ +// F257 approval executor route tests — auth gates, gate-error mapping, happy paths. +// Route-level unit tests: fake store + injected session (bootstrap integration for +// the store itself lives in hook-override-store.test.js). +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import Fastify from 'fastify'; + +import { OverrideGateError } from '../dist/domains/prompt-hooks/HookOverrideStore.js'; +import { promptInjectionOverrideRoutes } from '../dist/routes/prompt-injection-overrides.js'; + +const OWNER = 'test-owner'; + +function createFakeStore() { + const calls = []; + const overrides = new Map(); + return { + calls, + overrides, + async enable(hookId, actorId, opts) { + calls.push({ method: 'enable', hookId, actorId, opts }); + overrides.set(hookId, { hookId, enabled: true, enabledSource: opts?.source }); + }, + async disable(hookId, actorId, opts) { + if (hookId === 's1-immutable') { + throw new OverrideGateError(hookId, 'disable', 'disableable', false); + } + if (hookId === 'no-such-hook') { + throw new OverrideGateError(hookId, 'disable', 'unknown-hook', 'missing'); + } + calls.push({ method: 'disable', hookId, actorId, opts }); + overrides.set(hookId, { hookId, enabled: false, enabledSource: opts?.source }); + }, + async rollback(hookId, actorId, opts) { + if (hookId === 'no-such-hook') { + // Mirrors real store contract: rollback resolves manifest fail-closed (terra P2) + throw new OverrideGateError(hookId, 'rollback', 'unknown-hook', 'not-found'); + } + calls.push({ method: 'rollback', hookId, actorId, opts }); + overrides.delete(hookId); + }, + async getOverride(hookId) { + return overrides.get(hookId) ?? null; + }, + async listOverrides() { + return [...overrides.values()]; + }, + }; +} + +async function buildApp({ store = createFakeStore(), sessionUserId = OWNER } = {}) { + const app = Fastify(); + if (sessionUserId) { + app.addHook('onRequest', (req, _reply, done) => { + req.sessionUserId = sessionUserId; + done(); + }); + } + await app.register(promptInjectionOverrideRoutes, { overrideStore: store }); + await app.ready(); + return { app, store }; +} + +describe('prompt-injection-overrides routes (F257 approval executor)', () => { + before(() => { + // Owner gate: configured owner must match session user for writes. + process.env.DEFAULT_OWNER_USER_ID = OWNER; + }); + + it('401 without session (read + write)', async () => { + const { app } = await buildApp({ sessionUserId: null }); + const read = await app.inject({ method: 'GET', url: '/api/prompt-hooks/overrides' }); + assert.equal(read.statusCode, 401); + const write = await app.inject({ + method: 'POST', + url: '/api/prompt-hooks/d21-决策树/override', + payload: { action: 'disable', reason: 'x' }, + }); + assert.equal(write.statusCode, 401); + await app.close(); + }); + + it('403 when session user is not the configured owner', async () => { + const { app, store } = await buildApp({ sessionUserId: 'someone-else' }); + const res = await app.inject({ + method: 'POST', + url: '/api/prompt-hooks/d21-决策树/override', + payload: { action: 'disable', reason: 'trial' }, + }); + assert.equal(res.statusCode, 403); + assert.equal(store.calls.length, 0, 'store must not be touched'); + await app.close(); + }); + + it('400 on missing/invalid action and on missing reason', async () => { + const { app, store } = await buildApp(); + const badAction = await app.inject({ + method: 'POST', + url: '/api/prompt-hooks/d21-决策树/override', + payload: { action: 'set-content', reason: 'x' }, + }); + assert.equal(badAction.statusCode, 400); + const noReason = await app.inject({ + method: 'POST', + url: '/api/prompt-hooks/d21-决策树/override', + payload: { action: 'disable', reason: ' ' }, + }); + assert.equal(noReason.statusCode, 400); + assert.match(noReason.json().error, /reason/); + assert.equal(store.calls.length, 0); + await app.close(); + }); + + it('400 on non-string reason — untrusted input must not 500 (terra P2)', async () => { + const { app, store } = await buildApp(); + for (const reason of [{ bad: 'not-string' }, ['array'], 123]) { + const res = await app.inject({ + method: 'POST', + url: '/api/prompt-hooks/d21-决策树/override', + payload: { action: 'disable', reason }, + }); + assert.equal(res.statusCode, 400, `reason=${JSON.stringify(reason)} must map to 400`); + assert.match(res.json().error, /reason/); + } + assert.equal(store.calls.length, 0); + await app.close(); + }); + + it('400 on non-record body and non-string action', async () => { + const { app, store } = await buildApp(); + const stringBody = await app.inject({ + method: 'POST', + url: '/api/prompt-hooks/d21-决策树/override', + headers: { 'content-type': 'application/json' }, + payload: '"just-a-string"', + }); + assert.equal(stringBody.statusCode, 400); + const arrayBody = await app.inject({ + method: 'POST', + url: '/api/prompt-hooks/d21-决策树/override', + payload: [1, 2, 3], + }); + assert.equal(arrayBody.statusCode, 400); + const numericAction = await app.inject({ + method: 'POST', + url: '/api/prompt-hooks/d21-决策树/override', + payload: { action: 123, reason: 'x' }, + }); + assert.equal(numericAction.statusCode, 400); + assert.equal(store.calls.length, 0); + await app.close(); + }); + + it('disable happy path: store called with operator source + actor + reason, override echoed', async () => { + const { app, store } = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/prompt-hooks/d21-决策树/override', + payload: { action: 'disable', reason: 'T1-F1 redundancy trial (operator approved)' }, + }); + assert.equal(res.statusCode, 200); + const body = res.json(); + assert.equal(body.ok, true); + assert.equal(body.override.enabled, false); + assert.deepEqual(store.calls[0], { + method: 'disable', + hookId: 'd21-决策树', + actorId: OWNER, + opts: { source: 'operator', reason: 'T1-F1 redundancy trial (operator approved)' }, + }); + await app.close(); + }); + + it('rollback happy path clears the override', async () => { + const { app, store } = await buildApp(); + await app.inject({ + method: 'POST', + url: '/api/prompt-hooks/d21-决策树/override', + payload: { action: 'disable', reason: 'trial' }, + }); + const res = await app.inject({ + method: 'POST', + url: '/api/prompt-hooks/d21-决策树/override', + payload: { action: 'rollback', reason: 'trial regressed — instant revert' }, + }); + assert.equal(res.statusCode, 200); + assert.equal(res.json().override, null); + assert.equal(store.calls.at(-1).method, 'rollback'); + await app.close(); + }); + + it('gate errors map to HTTP: disableable=false → 409, unknown-hook → 404', async () => { + const { app } = await buildApp(); + const policy = await app.inject({ + method: 'POST', + url: '/api/prompt-hooks/s1-immutable/override', + payload: { action: 'disable', reason: 'x' }, + }); + assert.equal(policy.statusCode, 409); + assert.equal(policy.json().gate, 'disableable'); + const missing = await app.inject({ + method: 'POST', + url: '/api/prompt-hooks/no-such-hook/override', + payload: { action: 'disable', reason: 'x' }, + }); + assert.equal(missing.statusCode, 404); + assert.equal(missing.json().gate, 'unknown-hook'); + await app.close(); + }); + + it('unknown-hook rollback → 404 with no store write (terra P2: audit stream protection)', async () => { + const { app, store } = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/prompt-hooks/no-such-hook/override', + payload: { action: 'rollback', reason: 'cleanup attempt' }, + }); + assert.equal(res.statusCode, 404); + assert.equal(res.json().gate, 'unknown-hook'); + assert.equal(store.calls.length, 0, 'rollback must not be recorded for unknown hook'); + await app.close(); + }); + + it('GET lists current overrides (lifeline read surface)', async () => { + const { app } = await buildApp(); + await app.inject({ + method: 'POST', + url: '/api/prompt-hooks/d21-决策树/override', + payload: { action: 'disable', reason: 'trial' }, + }); + const res = await app.inject({ method: 'GET', url: '/api/prompt-hooks/overrides' }); + assert.equal(res.statusCode, 200); + assert.equal(res.json().overrides.length, 1); + await app.close(); + }); + + it('503 when override store unavailable (redis off)', async () => { + const app = Fastify(); + app.addHook('onRequest', (req, _reply, done) => { + req.sessionUserId = OWNER; + done(); + }); + await app.register(promptInjectionOverrideRoutes, { overrideStore: undefined }); + await app.ready(); + const res = await app.inject({ + method: 'POST', + url: '/api/prompt-hooks/d21-决策树/override', + payload: { action: 'disable', reason: 'x' }, + }); + assert.equal(res.statusCode, 503); + await app.close(); + }); +}); diff --git a/packages/api/test/segment-lifeline-chain.test.js b/packages/api/test/segment-lifeline-chain.test.js new file mode 100644 index 0000000000..237d1f67f0 --- /dev/null +++ b/packages/api/test/segment-lifeline-chain.test.js @@ -0,0 +1,1043 @@ +/** + * F257 Phase D — buildVersionChain() unit tests. + * + * Red tests for review findings: + * P1-1: activeVersion computation wrong (contentVersion=1 maps to manifest v1) + * P1-2: eval judgment unconditionally attached to latest epoch + * P2-3: no direct tests existed + * + * Scenarios from reviewer (terra/codex): + * 1. Create and activate V2 (first content override) + * 2. V2 exists but V1 still active (after rollback) + * 3. Rollback then re-create (V3) + * 4. Old eval NOT attributed to new version + * 5. segmentVersion preserved in judgment attachment + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +// Helper: create a minimal OverrideChangeEvent for tests +function makeEvent(partial) { + return { + eventId: `evt-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + hookId: 'S1', + workspaceId: 'default', + source: 'operator', + timestamp: 0, + actorId: 'user1', + ...partial, + }; +} + +describe('buildVersionChain', () => { + /** @type {typeof import('../dist/routes/segment-lifeline-chain.js').buildVersionChain} */ + let buildVersionChain; + /** @type {typeof import('../dist/routes/segment-lifeline-chain.js').attributeGuardEventsToEpochs} */ + let attributeGuardEventsToEpochs; + + test('setup: import chain builder', async () => { + const mod = await import('../dist/routes/segment-lifeline-chain.js'); + buildVersionChain = mod.buildVersionChain; + attributeGuardEventsToEpochs = mod.attributeGuardEventsToEpochs; + assert.ok(buildVersionChain, 'buildVersionChain exported'); + assert.ok(attributeGuardEventsToEpochs, 'attributeGuardEventsToEpochs exported'); + }); + + // ── Baseline ───────────────────────────────────────────────── + + test('manifest-only segment produces single v1 epoch', async () => { + const { chain } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [], + observations: [], + cachedJudgment: null, + currentContentVersion: null, + }); + + assert.equal(chain.length, 1); + assert.equal(chain[0].version, 1); + assert.equal(chain[0].origin, 'manifest'); + assert.equal(chain[0].isActive, true); + assert.equal(chain[0].status, 'idle'); + }); + + // ── P1-1 scenario 1: Create and activate V2 ───────────────── + + test('first content-set creates v2 epoch and marks it active', async () => { + const { chain } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [makeEvent({ action: 'content-set', timestamp: 1000 })], + observations: [], + cachedJudgment: null, + currentContentVersion: 1, + }); + + assert.equal(chain.length, 2, 'should have 2 epochs'); + // V1 = manifest baseline, NOT active + assert.equal(chain[0].version, 1); + assert.equal(chain[0].isActive, false, 'v1 should NOT be active when content override exists'); + // V2 = first override, IS active + assert.equal(chain[1].version, 2); + assert.equal(chain[1].isActive, true, 'v2 should be active when contentVersion=1'); + }); + + // ── P1-1 scenario 2: V2 exists but V1 still active ────────── + + test('v2 exists but v1 is active after rollback', async () => { + const { chain } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [ + makeEvent({ action: 'content-set', timestamp: 1000 }), + makeEvent({ action: 'rollback', timestamp: 2000 }), + ], + observations: [], + cachedJudgment: null, + currentContentVersion: null, // rolled back → contentVersion cleared + }); + + assert.equal(chain.length, 2, 'rollback does not remove epoch'); + assert.equal(chain[0].version, 1); + assert.equal(chain[0].isActive, true, 'v1 should be active after rollback'); + assert.equal(chain[1].version, 2); + assert.equal(chain[1].isActive, false, 'v2 should NOT be active after rollback'); + }); + + // ── P1-1 scenario 3: Rollback then re-create ──────────────── + + test('rollback then new content-set → v3 active, not v2', async () => { + const { chain } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [ + makeEvent({ action: 'content-set', timestamp: 1000 }), + makeEvent({ action: 'rollback', timestamp: 2000 }), + makeEvent({ action: 'content-set', timestamp: 3000 }), + ], + observations: [], + cachedJudgment: null, + currentContentVersion: 1, // contentVersion restarted after rollback+re-create + }); + + assert.equal(chain.length, 3, 'should have 3 epochs'); + assert.equal(chain[0].version, 1); + assert.equal(chain[0].isActive, false); + assert.equal(chain[1].version, 2); + assert.equal(chain[1].isActive, false, 'v2 (old override) should NOT be active'); + assert.equal(chain[2].version, 3); + assert.equal(chain[2].isActive, true, 'v3 (latest override) should be active'); + }); + + // ── P1-2 scenario 4: Old eval NOT on new version ──────────── + + test('eval judgment at t=500 is NOT attached to v2 created at t=1000', async () => { + const { chain } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [makeEvent({ action: 'content-set', timestamp: 1000 })], + observations: [], + cachedJudgment: { + segmentId: 'S1', + verdict: 'alive', + injectionCount: 10, + violationCount: 0, + correlationConfidence: 'window', + evaluatedAt: 500, // BEFORE v2 was created at t=1000 + runId: 'run1', + segmentVersion: 1, + }, + currentContentVersion: 1, + }); + + // Eval ran at t=500 when only v1 existed → should be on v1 + assert.ok(chain[0].eval, 'v1 should have eval data'); + assert.equal(chain[0].eval.verdict, 'alive'); + // V2 should NOT have eval data (it didn't exist when eval ran) + assert.equal(chain[1].eval, null, 'v2 should NOT have eval (created after eval ran)'); + }); + + test('eval judgment at t=1500 IS attached to v2 created at t=1000', async () => { + const { chain } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [makeEvent({ action: 'content-set', timestamp: 1000 })], + observations: [], + cachedJudgment: { + segmentId: 'S1', + verdict: 'dormant', + injectionCount: 5, + violationCount: 3, + correlationConfidence: 'window', + evaluatedAt: 1500, // AFTER v2 was created at t=1000 + runId: 'run2', + // R8: segmentVersion=2 matches epoch v2 (pipeline stamps activeEpochVersion). + // Pre-R8 this was 1, but R8 version-aware attribution would send it to v1. + segmentVersion: 2, + }, + currentContentVersion: 1, + }); + + // Eval ran at t=1500 when v2 was current → should be on v2 + assert.equal(chain[0].eval, null, 'v1 should NOT have eval'); + assert.ok(chain[1].eval, 'v2 should have eval data'); + assert.equal(chain[1].eval.verdict, 'dormant'); + }); + + // ── Observation attachment ─────────────────────────────────── + + test('observations are attached to correct epoch by timestamp', async () => { + const { chain } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [makeEvent({ action: 'content-set', timestamp: 1000 })], + observations: [ + { timestamp: 500, version: null }, // before v2 + { timestamp: 800, version: null }, // before v2 + { timestamp: 1200, version: null }, // after v2 + { timestamp: 1500, version: null }, // after v2 + ], + cachedJudgment: null, + currentContentVersion: 1, + }); + + assert.equal(chain[0].tracing?.observationCount, 2, 'v1 should have 2 observations'); + assert.equal(chain[1].tracing?.observationCount, 2, 'v2 should have 2 observations'); + }); + + test('observations with explicit contentVersion use timestamp, not version-number match (R2 P1-1)', async () => { + // Scenario: first content-set creates epoch v2 (chain auto-increments from manifest v1=1). + // HookRegistry records contentVersion=1 in traces. + // Old bug: findEpochForObservation matched version=1 → manifest epoch v1 (WRONG). + // Fix: timestamp-based matching → obs at t=1200 (after v2 created at t=1000) → epoch v2. + const { chain } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [makeEvent({ action: 'content-set', timestamp: 1000 })], + observations: [ + { timestamp: 500, version: 1 }, // before v2 — contentVersion=1, should go to v1 by timestamp + { timestamp: 1200, version: 1 }, // after v2 — contentVersion=1, MUST go to v2 by timestamp (not v1!) + { timestamp: 1500, version: 2 }, // after v2 — contentVersion=2 from second implicit trace + ], + cachedJudgment: null, + currentContentVersion: 1, + }); + + assert.equal(chain[0].tracing?.observationCount, 1, 'v1 epoch should have 1 observation (t=500)'); + assert.equal(chain[1].tracing?.observationCount, 2, 'v2 epoch should have 2 observations (t=1200 + t=1500)'); + }); + + test('activeVersion in chain derives from isActive epoch, not raw contentVersion', async () => { + // First content-set: chain epoch v2, contentVersion=1. They must not be confused. + const { chain } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [makeEvent({ action: 'content-set', timestamp: 1000 })], + observations: [], + cachedJudgment: null, + currentContentVersion: 1, + }); + + const activeEpoch = chain.find((e) => e.isActive); + assert.ok(activeEpoch, 'should have an active epoch'); + assert.equal(activeEpoch.version, 2, 'active epoch should be v2 (not contentVersion=1)'); + assert.equal(activeEpoch.origin, 'user-create', 'active epoch should be user-created override'); + }); + + // ── Rollback activation timeline (R3 P1-1) ────────────────── + + test('rollback → trace after rollback goes to v1, not v2 (R3 P1-1)', async () => { + // Terra reproduction: content-set@1000 → rollback@2000 → trace@2100 + // Old bug: startedAt-based matching put trace@2100 on v2 (2100 >= 1000). + // Fix: activation timeline tracks rollback → v1 active from t=2000. + const { chain } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [ + makeEvent({ action: 'content-set', timestamp: 1000 }), + makeEvent({ action: 'rollback', timestamp: 2000 }), + ], + observations: [ + { timestamp: 500, version: null }, // v1 active (before content-set) + { timestamp: 1500, version: null }, // v2 active (between content-set and rollback) + { timestamp: 2100, version: 1 }, // v1 active (after rollback) — MUST go to v1! + { timestamp: 3000, version: null }, // v1 active (after rollback) + ], + cachedJudgment: null, + currentContentVersion: null, // rollback clears content version + }); + + assert.equal(chain.length, 2, 'should have v1 + v2 epochs'); + assert.equal(chain[0].tracing?.observationCount, 3, 'v1 should have 3 obs (t=500, t=2100, t=3000)'); + assert.equal(chain[1].tracing?.observationCount, 1, 'v2 should have 1 obs (t=1500 only)'); + assert.equal(chain[0].isActive, true, 'v1 should be active after rollback'); + assert.equal(chain[1].isActive, false, 'v2 should NOT be active after rollback'); + }); + + test('rollback → eval after rollback goes to v1, not v2 (R3 P1-1)', async () => { + // Eval runs at t=2500, after rollback at t=2000. + // Must attach to v1 (active after rollback), not v2. + const { chain } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [ + makeEvent({ action: 'content-set', timestamp: 1000 }), + makeEvent({ action: 'rollback', timestamp: 2000 }), + ], + observations: [], + cachedJudgment: { + segmentId: 'S1', + verdict: 'alive', + injectionCount: 8, + violationCount: 0, + correlationConfidence: 'window', + evaluatedAt: 2500, // after rollback + runId: 'run-post-rollback', + segmentVersion: null, + }, + currentContentVersion: null, + }); + + assert.ok(chain[0].eval, 'v1 should have eval (eval ran while v1 active after rollback)'); + assert.equal(chain[0].eval.verdict, 'alive'); + assert.equal(chain[1].eval, null, 'v2 should NOT have eval'); + }); + + test('content-set → rollback → content-set: activation timeline tracks re-creation', async () => { + // v1 → v2@1000 → rollback@2000 → v3@3000 + // trace@2500 (between rollback and v3) → v1 + // trace@3500 (after v3) → v3 + const { chain } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [ + makeEvent({ action: 'content-set', timestamp: 1000 }), + makeEvent({ action: 'rollback', timestamp: 2000 }), + makeEvent({ action: 'content-set', timestamp: 3000 }), + ], + observations: [ + { timestamp: 500, version: null }, // v1 + { timestamp: 1500, version: null }, // v2 + { timestamp: 2500, version: null }, // v1 (after rollback) + { timestamp: 3500, version: null }, // v3 + ], + cachedJudgment: null, + currentContentVersion: 2, + }); + + assert.equal(chain.length, 3, 'should have v1, v2, v3'); + assert.equal(chain[0].tracing?.observationCount, 2, 'v1: t=500 + t=2500'); + assert.equal(chain[1].tracing?.observationCount, 1, 'v2: t=1500'); + assert.equal(chain[2].tracing?.observationCount, 1, 'v3: t=3500'); + assert.equal(chain[2].isActive, true, 'v3 should be active'); + }); + + // ── content-clear + same-ms edge cases (R4 P1-1) ──────────── + + test('content-clear reactivates manifest, trace goes to v1 (R4 P1-1)', async () => { + // content-clear removes content override like rollback but is a distinct action. + // Old bug: timeline only handled rollback, not content-clear. + const { chain } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [ + makeEvent({ action: 'content-set', timestamp: 1000 }), + makeEvent({ action: 'content-clear', timestamp: 2000 }), + ], + observations: [ + { timestamp: 1500, version: null }, // v2 active + { timestamp: 2500, version: null }, // v1 active (after content-clear) + ], + cachedJudgment: null, + currentContentVersion: null, + }); + + assert.equal(chain[0].tracing?.observationCount, 1, 'v1 should have 1 obs (t=2500, after content-clear)'); + assert.equal(chain[1].tracing?.observationCount, 1, 'v2 should have 1 obs (t=1500)'); + assert.equal(chain[0].isActive, true, 'v1 should be active after content-clear'); + }); + + test('content-clear: eval after clear goes to manifest (R4 P1-1)', async () => { + const { chain } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [ + makeEvent({ action: 'content-set', timestamp: 1000 }), + makeEvent({ action: 'content-clear', timestamp: 2000 }), + ], + observations: [], + cachedJudgment: { + segmentId: 'S1', + verdict: 'alive', + injectionCount: 5, + violationCount: 0, + correlationConfidence: 'window', + evaluatedAt: 2500, + runId: 'run-post-clear', + segmentVersion: null, + }, + currentContentVersion: null, + }); + + assert.ok(chain[0].eval, 'v1 should have eval (after content-clear)'); + assert.equal(chain[1].eval, null, 'v2 should NOT have eval'); + }); + + test('same-ms content-set events: each creates distinct epoch with correct activation (R4 P1-1)', async () => { + // Two content-set at t=1000: v2 and v3. + // Old bug: findIndex(startedAt===1000) always matched v2 for both. + // Fix: single-pass reducer directly assigns epochIndex at creation. + const { chain } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [ + makeEvent({ action: 'content-set', timestamp: 1000 }), + makeEvent({ action: 'content-set', timestamp: 1000 }), + ], + observations: [ + { timestamp: 500, version: null }, // v1 + { timestamp: 1200, version: null }, // v3 (latest of same-ms pair) + ], + cachedJudgment: null, + currentContentVersion: 2, + }); + + assert.equal(chain.length, 3, 'should have v1, v2, v3'); + assert.equal(chain[0].tracing?.observationCount, 1, 'v1: t=500'); + // v2 activated at t=1000 but immediately superseded by v3 at t=1000 + assert.equal(chain[1].tracing, null, 'v2: no observations (immediately superseded)'); + assert.equal(chain[2].tracing?.observationCount, 1, 'v3: t=1200 (latest same-ms activation)'); + assert.equal(chain[2].isActive, true, 'v3 should be active'); + }); + + test('same-ms rollback→content-set: events in correct order → trace goes to v2 (R5 P1-1)', async () => { + // Simulates real ZSET output AFTER event ID format fix: + // rollback (seq=0) sorts before content-set (seq=1) at same timestamp. + // Physical order: content-set (create v2) → rollback → content-set (create v3) + // After rollback+content-set at t=2000, v3 is active, trace@2500 → v3. + const { chain } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [ + makeEvent({ action: 'content-set', timestamp: 1000 }), + makeEvent({ action: 'rollback', timestamp: 2000 }), + makeEvent({ action: 'content-set', timestamp: 2000 }), // same ms, seq after rollback + ], + observations: [{ timestamp: 2500, version: null }], + cachedJudgment: null, + currentContentVersion: 2, + }); + + assert.equal(chain.length, 3, 'v1 + v2 + v3'); + // v3 was created at t=2000 (after rollback at t=2000), v3 is latest active + assert.equal(chain[2].isActive, true, 'v3 should be active'); + assert.equal(chain[2].tracing?.observationCount, 1, 'trace@2500 should go to v3 (active after rollback+create)'); + assert.equal(chain[0].tracing, null, 'v1 should have no observations'); + assert.equal(chain[1].tracing, null, 'v2 should have no observations'); + }); + + // ── Status derivation ──────────────────────────────────────── + + test('epoch with observations derives tracing status', async () => { + const { chain } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [], + observations: [{ timestamp: 1000, version: null }], + cachedJudgment: null, + currentContentVersion: null, + }); + + assert.equal(chain[0].status, 'tracing'); + }); + + test('alive verdict derives governance-pending status (eval triggers governance)', async () => { + const { chain } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [], + observations: [], + cachedJudgment: { + segmentId: 'S1', + verdict: 'alive', + injectionCount: 10, + violationCount: 0, + correlationConfidence: 'window', + evaluatedAt: 500, + runId: 'run1', + segmentVersion: null, + }, + currentContentVersion: null, + }); + + // alive verdict → governance=pending takes priority → governance-pending status + assert.equal(chain[0].status, 'governance-pending'); + }); + + test('retire-candidate verdict derives eval-reject status (no governance)', async () => { + const { chain } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [], + observations: [], + cachedJudgment: { + segmentId: 'S1', + verdict: 'retire-candidate', + injectionCount: 10, + violationCount: 8, + correlationConfidence: 'window', + evaluatedAt: 500, + runId: 'run1', + segmentVersion: null, + }, + currentContentVersion: null, + }); + + assert.equal(chain[0].status, 'eval-reject'); + }); + + // ── Governance events ──────────────────────────────────────── + + test('operator enable/disable events map to governance kinds (AF-5)', async () => { + const { chain } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [ + makeEvent({ action: 'disable', source: 'operator', timestamp: 1000 }), + makeEvent({ action: 'enable', source: 'operator', timestamp: 2000 }), + ], + observations: [], + cachedJudgment: null, + currentContentVersion: null, + }); + + assert.equal(chain[0].events.length, 2); + assert.equal(chain[0].events[0].kind, 'governance-reject', 'operator disable = governance-reject'); + assert.equal(chain[0].events[1].kind, 'governance-approve', 'operator enable = governance-approve'); + }); + + test('auto-eval enable/disable events map to eval kinds (AF-5)', async () => { + const { chain } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [ + makeEvent({ action: 'disable', source: 'auto-eval', timestamp: 1000 }), + makeEvent({ action: 'enable', source: 'auto-eval', timestamp: 2000 }), + ], + observations: [], + cachedJudgment: null, + currentContentVersion: null, + }); + + assert.equal(chain[0].events.length, 2); + assert.equal(chain[0].events[0].kind, 'eval-reject', 'auto-eval disable = eval-reject'); + assert.equal(chain[0].events[1].kind, 'eval-pass', 'auto-eval enable = eval-pass'); + }); + + // ── Multiple content versions ──────────────────────────────── + + test('two content-set events create v1, v2, v3 with v3 active', async () => { + const { chain } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [ + makeEvent({ action: 'content-set', timestamp: 1000 }), + makeEvent({ action: 'content-set', timestamp: 2000 }), + ], + observations: [], + cachedJudgment: null, + currentContentVersion: 2, + }); + + assert.equal(chain.length, 3); + assert.equal(chain[0].version, 1); + assert.equal(chain[0].isActive, false); + assert.equal(chain[1].version, 2); + assert.equal(chain[1].isActive, false); + assert.equal(chain[2].version, 3); + assert.equal(chain[2].isActive, true, 'v3 (latest content-set) should be active'); + }); + + // ── P1-2: per-version eval history ────────────────────────── + + test('multiple judgments distributed across epochs by activation timeline', async () => { + // v1 (manifest) → content-set@1000 (v2) → content-set@2000 (v3) + // judgment1 at t=500 → v1, judgment2 at t=1500 → v2, judgment3 at t=2500 → v3 + const { chain } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [ + makeEvent({ action: 'content-set', timestamp: 1000 }), + makeEvent({ action: 'content-set', timestamp: 2000 }), + ], + observations: [], + judgmentHistory: [ + { + segmentId: 'S1', + verdict: 'alive', + injectionCount: 10, + violationCount: 0, + correlationConfidence: 'window', + evaluatedAt: 500, + runId: 'run1', + segmentVersion: 1, + }, + { + segmentId: 'S1', + verdict: 'dormant', + injectionCount: 5, + violationCount: 3, + correlationConfidence: 'window', + evaluatedAt: 1500, + runId: 'run2', + segmentVersion: null, + }, + { + segmentId: 'S1', + verdict: 'retire-candidate', + injectionCount: 0, + violationCount: 7, + correlationConfidence: 'strong', + evaluatedAt: 2500, + runId: 'run3', + segmentVersion: null, + }, + ], + currentContentVersion: 2, + }); + + assert.equal(chain.length, 3); + // v1 got judgment1 (alive at t=500) + assert.ok(chain[0].eval, 'v1 should have eval'); + assert.equal(chain[0].eval.verdict, 'alive'); + assert.equal(chain[0].eval.evaluatedAt, 500); + // v2 got judgment2 (dormant at t=1500) + assert.ok(chain[1].eval, 'v2 should have eval'); + assert.equal(chain[1].eval.verdict, 'dormant'); + assert.equal(chain[1].eval.evaluatedAt, 1500); + // v3 got judgment3 (retire-candidate at t=2500) + assert.ok(chain[2].eval, 'v3 should have eval'); + assert.equal(chain[2].eval.verdict, 'retire-candidate'); + assert.equal(chain[2].eval.evaluatedAt, 2500); + }); + + test('latest judgment wins when multiple map to same epoch', async () => { + // Two evals during v1 lifetime (no override events) + const { chain } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [], + observations: [], + judgmentHistory: [ + { + segmentId: 'S1', + verdict: 'dormant', + injectionCount: 3, + violationCount: 2, + correlationConfidence: 'window', + evaluatedAt: 500, + runId: 'run1', + segmentVersion: 1, + }, + { + segmentId: 'S1', + verdict: 'alive', + injectionCount: 10, + violationCount: 0, + correlationConfidence: 'strong', + evaluatedAt: 1000, + runId: 'run2', + segmentVersion: 1, + }, + ], + currentContentVersion: null, + }); + + assert.equal(chain.length, 1); + // Latest judgment (alive at t=1000) wins + assert.ok(chain[0].eval, 'v1 should have eval'); + assert.equal(chain[0].eval.verdict, 'alive'); + assert.equal(chain[0].eval.evaluatedAt, 1000); + }); + + // ── P1-3: version-activate event in chain ──────────────────── + + test('version-activate switches active epoch back to earlier version (epochVersion)', async () => { + // v1 → content-set@1000 (v2) → content-set@2000 (v3) → version-activate epochVersion=2 @3000 + const { chain } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [ + makeEvent({ action: 'content-set', timestamp: 1000 }), + makeEvent({ action: 'content-set', timestamp: 2000 }), + makeEvent({ action: 'version-activate', timestamp: 3000, epochVersion: 2 }), + ], + observations: [], + judgmentHistory: [], + currentContentVersion: 2, + }); + + assert.equal(chain.length, 3); + assert.equal(chain[0].isActive, false, 'v1 not active'); + assert.equal(chain[1].isActive, true, 'v2 should be active after version-activate'); + assert.equal(chain[2].isActive, false, 'v3 not active'); + }); + + test('observation after version-activate goes to activated epoch (epochVersion)', async () => { + // v1 → content-set@1000 (v2) → version-activate epochVersion=1 @2000 → observation@2500 + const { chain } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [ + makeEvent({ action: 'content-set', timestamp: 1000 }), + makeEvent({ action: 'version-activate', timestamp: 2000, epochVersion: 1 }), + ], + observations: [{ timestamp: 2500, version: null }], + judgmentHistory: [], + currentContentVersion: 1, + }); + + assert.equal(chain.length, 2); + // Observation at t=2500 should go to v1 (activated at t=2000) + assert.ok(chain[0].tracing, 'v1 should have tracing data'); + assert.equal(chain[0].tracing.observationCount, 1); + assert.equal(chain[1].tracing, null, 'v2 should have no tracing'); + }); + + test('epochVersion takes precedence over contentVersion in version-activate (R6 regression guard)', async () => { + // R6 bug: contentVersion=1 collides with manifest epoch.version=1 + // epochVersion=2 correctly targets the first override epoch + // This test uses BOTH fields — epochVersion must win + const { chain } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [ + makeEvent({ action: 'content-set', timestamp: 1000 }), + makeEvent({ action: 'content-set', timestamp: 2000 }), + // contentVersion=1 would match manifest (WRONG), epochVersion=2 matches first override (RIGHT) + makeEvent({ action: 'version-activate', timestamp: 3000, contentVersion: 1, epochVersion: 2 }), + ], + observations: [], + judgmentHistory: [], + currentContentVersion: 1, + }); + + assert.equal(chain.length, 3); + assert.equal(chain[0].isActive, false, 'v1 (manifest) should NOT be active — contentVersion=1 must not win'); + assert.equal(chain[1].isActive, true, 'v2 (first override) should be active via epochVersion=2'); + assert.equal(chain[2].isActive, false, 'v3 not active'); + }); + + test('backward compat: contentVersion used when epochVersion absent (pre-R6 events)', async () => { + // Pre-R6 events only have contentVersion. Chain builder falls back. + const { chain } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [ + makeEvent({ action: 'content-set', timestamp: 1000 }), + // No epochVersion — uses contentVersion=2 which matches epoch.version=2 + makeEvent({ action: 'version-activate', timestamp: 2000, contentVersion: 2 }), + ], + observations: [], + judgmentHistory: [], + currentContentVersion: 2, + }); + + assert.equal(chain.length, 2); + assert.equal(chain[0].isActive, false, 'v1 not active'); + assert.equal(chain[1].isActive, true, 'v2 active via contentVersion fallback'); + }); + + // ── R8: epochVersion as truth source in chain builder ────── + + test('R8 P1-1: judgment with segmentVersion goes to matching epoch, not timeline (Red→Green)', async () => { + // Two content-set events create v2 and v3 epochs. + // Two judgments in the SAME eval window (same evaluatedAt) but different segmentVersion. + // Without the fix, both fall to the same timeline-resolved epoch (v3, the last active). + const { chain } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [ + makeEvent({ action: 'content-set', timestamp: 1000, epochVersion: 2 }), + makeEvent({ action: 'content-set', timestamp: 2000, epochVersion: 3 }), + ], + observations: [], + judgmentHistory: [ + { + segmentId: 'S1', + verdict: 'alive', + injectionCount: 5, + violationCount: 0, + correlationConfidence: 'window', + evaluatedAt: 5000, + runId: 'r1', + segmentVersion: 2, + }, + { + segmentId: 'S1', + verdict: 'unmeasurable', + injectionCount: 0, + violationCount: 0, + correlationConfidence: 'window', + evaluatedAt: 5000, + runId: 'r1', + segmentVersion: 3, + }, + ], + currentContentVersion: 2, + }); + + assert.equal(chain.length, 3, 'manifest + 2 overrides'); + const v2 = chain.find((e) => e.version === 2); + const v3 = chain.find((e) => e.version === 3); + assert.ok(v2, 'v2 epoch must exist'); + assert.ok(v3, 'v3 epoch must exist'); + assert.ok(v2.eval, 'v2 should have eval'); + assert.equal(v2.eval.verdict, 'alive', 'v2 gets its own verdict (alive)'); + assert.ok(v3.eval, 'v3 should have eval'); + assert.equal(v3.eval.verdict, 'unmeasurable', 'v3 gets its own verdict (unmeasurable)'); + }); + + test('R8 P1-2: epochs use epochVersion from events, not incremental (Red→Green)', async () => { + // Events arrive out of order: epochVersion 3 first, then 2 + // (concurrent write: B got epoch=3, wrote first; A got epoch=2, wrote second) + const { chain } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [ + makeEvent({ action: 'content-set', timestamp: 1000, epochVersion: 3 }), + makeEvent({ action: 'content-set', timestamp: 2000, epochVersion: 2 }), + ], + observations: [], + judgmentHistory: [], + currentContentVersion: 1, + }); + + assert.equal(chain.length, 3); + assert.equal(chain[0].version, 1, 'manifest epoch'); + assert.equal(chain[1].version, 3, 'first event epoch uses epochVersion=3'); + assert.equal(chain[2].version, 2, 'second event epoch uses epochVersion=2'); + // Last event's epoch should be active (A wrote last) + assert.equal(chain[2].isActive, true, 'v2 is active (last write)'); + assert.equal(chain[1].isActive, false, 'v3 is NOT active'); + }); + + test('R8 backward compat: events without epochVersion use incremental fallback', async () => { + const { chain } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [ + makeEvent({ action: 'content-set', timestamp: 1000 }), + makeEvent({ action: 'content-set', timestamp: 2000 }), + ], + observations: [], + judgmentHistory: [], + currentContentVersion: 2, + }); + + assert.equal(chain[1].version, 2, 'fallback: manifest(1) + 1'); + assert.equal(chain[2].version, 3, 'fallback: 2 + 1'); + }); + + test('R8 version-activate finds epoch by real epochVersion (not incremental)', async () => { + // Out-of-order epochs: v3 created first, then v2 + // version-activate targets v3 — must find it correctly + const { chain } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [ + makeEvent({ action: 'content-set', timestamp: 1000, epochVersion: 3 }), + makeEvent({ action: 'content-set', timestamp: 2000, epochVersion: 2 }), + makeEvent({ action: 'version-activate', timestamp: 3000, epochVersion: 3 }), + ], + observations: [], + judgmentHistory: [], + currentContentVersion: 1, + }); + + assert.equal(chain.length, 3); + assert.equal(chain[1].isActive, true, 'v3 (epoch index 1) reactivated'); + assert.equal(chain[2].isActive, false, 'v2 not active'); + }); + + // ── R9: events attach to ACTIVE epoch, not last-created ───── + // + // State transition table (lifecycle state machine): + // State: { epochs[], activeIdx } + // content-set: event → epochs[activeIdx], create new, activeIdx = new + // version-activate: event → epochs[activeIdx], activeIdx = target + // rollback: event → epochs[activeIdx], activeIdx = 0 + // content-clear: event → epochs[activeIdx], activeIdx = 0 + // enable/disable: event → epochs[activeIdx], no active change + + test('R9: disable after activate(v2) goes to v2, not v3 (Red→Green)', async () => { + // v1 → content-set(v2) → content-set(v3) → activate(v2) → disable + // disable should be on v2 (active), NOT v3 (last created) + const { chain } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [ + makeEvent({ action: 'content-set', timestamp: 1000, epochVersion: 2 }), + makeEvent({ action: 'content-set', timestamp: 2000, epochVersion: 3 }), + makeEvent({ action: 'version-activate', timestamp: 3000, epochVersion: 2 }), + makeEvent({ action: 'disable', timestamp: 4000 }), + ], + observations: [], + judgmentHistory: [], + currentContentVersion: 1, + }); + + assert.equal(chain.length, 3); + const v2 = chain.find((e) => e.version === 2); + const v3 = chain.find((e) => e.version === 3); + // activate event should be on v3 (was active when activate happened) + const v3Events = v3.events.map((e) => e.kind); + assert.ok(v3Events.includes('version-activate'), 'activate event on v3 (was active)'); + // disable event should be on v2 (active after activate) + const v2Events = v2.events.map((e) => e.kind); + assert.ok(v2Events.includes('governance-reject'), 'disable event on v2 (now active)'); + // v2 should be active + assert.equal(v2.isActive, true); + }); + + test('R9: content-set after activate(v2) branches from v2 (Red→Green)', async () => { + // v1 → content-set(v2) → content-set(v3) → activate(v2) → content-set(v4) + // The v4 creation event should be on v2 ("v2 → v4"), not v3 + const { chain } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [ + makeEvent({ action: 'content-set', timestamp: 1000, epochVersion: 2 }), + makeEvent({ action: 'content-set', timestamp: 2000, epochVersion: 3 }), + makeEvent({ action: 'version-activate', timestamp: 3000, epochVersion: 2 }), + makeEvent({ action: 'content-set', timestamp: 4000, epochVersion: 4 }), + ], + observations: [], + judgmentHistory: [], + currentContentVersion: 1, + }); + + assert.equal(chain.length, 4); + const v2 = chain.find((e) => e.version === 2); + // v2 should have the "v2 → v4" creation event (it was active when content-set happened) + const v2Details = v2.events.map((e) => e.detail); + assert.ok( + v2Details.some((d) => d.includes('v2') && d.includes('v4')), + 'v2 has "v2 → v4" event', + ); + // v4 should be active (last content-set) + const v4 = chain.find((e) => e.version === 4); + assert.equal(v4.isActive, true, 'v4 is active'); + }); + + test('R9: rollback event goes to active epoch, not last-created (Red→Green)', async () => { + // v1 → content-set(v2) → content-set(v3) → activate(v2) → rollback + // rollback event should be on v2 (active), not v3 (last created) + const { chain } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [ + makeEvent({ action: 'content-set', timestamp: 1000, epochVersion: 2 }), + makeEvent({ action: 'content-set', timestamp: 2000, epochVersion: 3 }), + makeEvent({ action: 'version-activate', timestamp: 3000, epochVersion: 2 }), + makeEvent({ action: 'rollback', timestamp: 4000 }), + ], + observations: [], + judgmentHistory: [], + currentContentVersion: 1, + }); + + const v2 = chain.find((e) => e.version === 2); + const v2Events = v2.events.map((e) => e.detail); + assert.ok( + v2Events.some((d) => d.includes('rolled back')), + 'rollback event on v2 (was active)', + ); + // After rollback, manifest is active + assert.equal(chain[0].isActive, true, 'v1 (manifest) active after rollback'); + }); + + // ── R15: guard event attribution via activation timeline ───── + + test('R15: v1→v2→rollback(v1)→activate(v2) guard attribution regression', async () => { + // Timeline: t=0 v1 active, t=100 v2 active, t=200 rollback→v1, t=300 activate→v2 + const { chain, timeline } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [ + makeEvent({ action: 'content-set', timestamp: 100, epochVersion: 2 }), + makeEvent({ action: 'rollback', timestamp: 200 }), + makeEvent({ action: 'version-activate', timestamp: 300, epochVersion: 2 }), + ], + observations: [], + judgmentHistory: [], + currentContentVersion: 2, + }); + const metrics = attributeGuardEventsToEpochs(chain, timeline, [ + { timestamp: 50, guardId: 'g1' }, // v1 active + { timestamp: 150, guardId: 'g1' }, // v2 active + { timestamp: 250, guardId: 'g1' }, // v1 active (post-rollback) + { timestamp: 350, guardId: 'g1' }, // v2 active (re-activated) + ]); + assert.deepEqual(metrics[1], [{ guardId: 'g1', count: 2 }], 'v1 gets t=50+t=250'); + assert.deepEqual(metrics[2], [{ guardId: 'g1', count: 2 }], 'v2 gets t=150+t=350'); + }); + + test('R15: per-guard grouping in attributed metrics', async () => { + const { chain, timeline } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [makeEvent({ action: 'content-set', timestamp: 100, epochVersion: 2 })], + observations: [], + judgmentHistory: [], + currentContentVersion: 2, + }); + const metrics = attributeGuardEventsToEpochs(chain, timeline, [ + { timestamp: 150, guardId: 'g-alpha' }, + { timestamp: 160, guardId: 'g-beta' }, + { timestamp: 170, guardId: 'g-alpha' }, + ]); + assert.deepEqual(metrics[1], [], 'v1 has no events'); + assert.equal(metrics[2].length, 2, 'v2 has 2 guard groups'); + assert.deepEqual(metrics[2][0], { guardId: 'g-alpha', count: 2 }); + assert.deepEqual(metrics[2][1], { guardId: 'g-beta', count: 1 }); + }); + + test('R15: empty guard events yields empty arrays per epoch', async () => { + const { chain, timeline } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [], + observations: [], + judgmentHistory: [], + currentContentVersion: null, + }); + const metrics = attributeGuardEventsToEpochs(chain, timeline, []); + assert.deepEqual(metrics[1], []); + }); + + test('R15: buildVersionChain returns { chain, timeline }', async () => { + const result = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [], + observations: [], + currentContentVersion: null, + }); + assert.ok(Array.isArray(result.chain), 'chain is array'); + assert.ok(Array.isArray(result.timeline), 'timeline is array'); + assert.equal(result.chain.length, 1); + assert.equal(result.timeline.length, 1); + }); + + test('rollback redistributes judgments: post-rollback eval goes to manifest', async () => { + // v1 → content-set@1000 (v2) → rollback@2000 → eval@2500 → content-set@3000 (v3) + const { chain } = buildVersionChain({ + manifestVersion: 1, + overrideEvents: [ + makeEvent({ action: 'content-set', timestamp: 1000 }), + makeEvent({ action: 'rollback', timestamp: 2000 }), + makeEvent({ action: 'content-set', timestamp: 3000 }), + ], + observations: [], + judgmentHistory: [ + { + segmentId: 'S1', + verdict: 'dormant', + injectionCount: 5, + violationCount: 3, + correlationConfidence: 'window', + evaluatedAt: 1500, + runId: 'run1', + segmentVersion: null, + }, + { + segmentId: 'S1', + verdict: 'alive', + injectionCount: 12, + violationCount: 0, + correlationConfidence: 'strong', + evaluatedAt: 2500, + runId: 'run2', + segmentVersion: null, + }, + ], + currentContentVersion: 2, + }); + + assert.equal(chain.length, 3); + // v1: eval@2500 (post-rollback, manifest active) + assert.ok(chain[0].eval, 'v1 should have eval (post-rollback)'); + assert.equal(chain[0].eval.verdict, 'alive'); + assert.equal(chain[0].eval.evaluatedAt, 2500); + // v2: eval@1500 (during v2 active period) + assert.ok(chain[1].eval, 'v2 should have eval'); + assert.equal(chain[1].eval.verdict, 'dormant'); + assert.equal(chain[1].eval.evaluatedAt, 1500); + // v3: no eval yet + assert.equal(chain[2].eval, null, 'v3 has no eval yet'); + }); +}); diff --git a/packages/api/test/segment-lifeline-replay.test.js b/packages/api/test/segment-lifeline-replay.test.js new file mode 100644 index 0000000000..60af74efd4 --- /dev/null +++ b/packages/api/test/segment-lifeline-replay.test.js @@ -0,0 +1,884 @@ +/** + * F257 Console 判据④ — Segment lifeline true-scene replay route tests. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import Fastify from 'fastify'; + +// ── Minimal FakeRedis with SET/ZSET/HASH/Lua support ───────── + +class FakeRedis { + constructor() { + this.kv = new Map(); + this.sorted = new Map(); + this.sets = new Map(); + this.hashes = new Map(); + this.ttls = new Map(); + } + + async set(key, value, ...args) { + this.kv.set(key, value); + if (args[0] === 'EX' && typeof args[1] === 'number') { + this.ttls.set(key, args[1]); + } + return 'OK'; + } + + async get(key) { + return this.kv.get(key) ?? null; + } + + async exists(key) { + if (this.kv.has(key)) return 1; + if (this.sorted.has(key)) return 1; + if (this.sets.has(key)) return 1; + if (this.hashes.has(key)) return 1; + return 0; + } + + async del(key) { + this.kv.delete(key); + this.sets.delete(key); + this.sorted.delete(key); + this.hashes.delete(key); + this.ttls.delete(key); + return 1; + } + + async zadd(key, score, member) { + const set = this.sorted.get(key) ?? new Map(); + set.set(member, score); + this.sorted.set(key, set); + return 1; + } + + async zcard(key) { + return this.sorted.get(key)?.size ?? 0; + } + + async zrevrange(key, start, stop) { + const set = this.sorted.get(key); + if (!set) return []; + const entries = [...set.entries()].sort((a, b) => b[1] - a[1]); + return entries.slice(start, stop + 1).map(([m]) => m); + } + + async zrangebyscore(key, min, max) { + const set = this.sorted.get(key); + if (!set) return []; + return [...set.entries()] + .filter(([, score]) => score >= min && score <= max) + .sort((a, b) => a[1] - b[1]) + .map(([m]) => m); + } + + async zrem(key, member) { + const set = this.sorted.get(key); + if (!set) return 0; + return set.delete(member) ? 1 : 0; + } + + async sadd(key, ...members) { + const s = this.sets.get(key) ?? new Set(); + let added = 0; + for (const m of members) { + if (!s.has(m)) { + s.add(m); + added++; + } + } + this.sets.set(key, s); + return added; + } + + async smembers(key) { + const s = this.sets.get(key); + return s ? [...s] : []; + } + + async hset(key, fields) { + const h = this.hashes.get(key) ?? new Map(); + for (const [field, value] of Object.entries(fields)) { + h.set(field, value); + } + this.hashes.set(key, h); + return 1; + } + + async hget(key, field) { + return this.hashes.get(key)?.get(field) ?? null; + } + + async hgetall(key) { + const h = this.hashes.get(key); + if (!h) return []; + const out = []; + for (const [k, v] of h) { + out.push(k, v); + } + return out; + } + + async hdel(key, field) { + const h = this.hashes.get(key); + if (!h) return 0; + return h.delete(field) ? 1 : 0; + } + + #runPersistScript(keys, argv) { + const summaryKey = keys[0]; + const hashKey = keys[1]; + const count = Number(argv[0]); + if (this.kv.has(summaryKey) === false) return 0; + const h = this.hashes.get(hashKey) ?? new Map(); + for (let i = 0; i < count; i++) { + const segmentId = argv[1 + i]; + const json = argv[1 + count + i]; + h.set(segmentId, json); + } + this.hashes.set(hashKey, h); + return 1; + } + + #runDeleteScript(keys, argv) { + const indexKey = keys[2]; + const turnId = argv[0]; + let removed = 0; + if (this.sorted.get(indexKey)?.delete(turnId)) removed = 1; + for (const k of keys) { + if ( + k !== indexKey && + (this.kv.delete(k) || this.sets.delete(k) || this.sorted.delete(k) || this.hashes.delete(k)) + ) { + removed++; + } + } + return removed; + } + + // Minimal eval interpreter for the two Lua scripts used by InjectionTraceStore. + async eval(script, numKeys, ...args) { + const keys = args.slice(0, numKeys); + const argv = args.slice(numKeys); + + if (script.includes("redis.call('EXISTS'") && script.includes("redis.call('HSET'")) { + return this.#runPersistScript(keys, argv); + } + if (script.includes("redis.call('ZREM'") && script.includes("redis.call('DEL'")) { + return this.#runDeleteScript(keys, argv); + } + + throw new Error(`FakeRedis.eval: unsupported script`); + } +} + +// ── Helpers ────────────────────────────────────────────────── + +async function seedTurn(traceStore, { threadId, turnId, catId = 'opus', timestamp = 5000 }) { + const summary = { + turnId, + threadId, + catId, + timestamp, + segments: [], + delivery: [], + totalCharCount: 0, + totalTokenEstimate: 0, + totalSegmentsObserved: 0, + totalSegmentsAbsent: 0, + durationMs: 0, + }; + const detail = { + turnId, + threadId, + catId, + timestamp, + sessionContentHash: null, + turnContentHash: null, + sessionCharCount: 0, + sessionTokenEstimate: 0, + turnCharCount: 0, + turnTokenEstimate: 0, + segments: [], + }; + await traceStore.persist(summary, detail); +} + +function makeSnapshot({ threadId, turnId, segmentId, catId = 'opus', timestamp = 5000, overrides = {} }) { + return { + segmentId, + threadId, + turnId, + timestamp, + catId, + stage: 'session-init', + pipelineStatus: 'fired', + version: 1, + content: 'rendered content', + contentSourceKind: 'template', + contentSourceRef: 'templates/S-test.md', + templateVars: { VAR: 'value' }, + messageAnchorId: 'anchor-1', + surroundingMessageIds: ['m1', 'm2'], + surroundingMessagesGap: null, + ownerUserId: 'test-user', + ...overrides, + }; +} + +async function buildReplayApp(opts = {}) { + const { segmentLifelineReplayRoutes } = await import('../dist/routes/segment-lifeline-replay.js'); + const app = Fastify({ logger: false }); + app.addHook('preHandler', async (request) => { + const sessionUser = request.headers['x-test-session-user']; + if (typeof sessionUser === 'string' && sessionUser.trim()) { + request.sessionUserId = sessionUser.trim(); + } + }); + await app.register(segmentLifelineReplayRoutes, opts); + await app.ready(); + return app; +} + +function makeThreadStore(ownerUserId = 'test-user') { + return { + get: async (threadId) => ({ + id: threadId, + projectPath: '/tmp', + title: null, + createdBy: ownerUserId, + participants: [], + lastActiveAt: Date.now(), + createdAt: Date.now(), + }), + }; +} + +const SESSION_HEADERS = { 'x-test-session-user': 'test-user' }; + +// ── Route tests ────────────────────────────────────────────── + +describe('segment-lifeline-replay route', () => { + test('returns 401 without session', async () => { + const app = await buildReplayApp({}); + const res = await app.inject({ + method: 'GET', + url: '/api/segment-lifeline/S-test/replay?threadId=t&turnId=1', + }); + assert.equal(res.statusCode, 401); + await app.close(); + }); + + test('returns 503 when trace store unavailable', async () => { + const app = await buildReplayApp({ threadStore: makeThreadStore() }); + const res = await app.inject({ + method: 'GET', + url: '/api/segment-lifeline/S-test/replay?threadId=t&turnId=1', + headers: SESSION_HEADERS, + }); + assert.equal(res.statusCode, 503); + await app.close(); + }); + + test('returns 503 when thread store unavailable', async () => { + const { InjectionTraceStore } = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + const store = new InjectionTraceStore(new FakeRedis()); + const app = await buildReplayApp({ traceStore: store }); + const res = await app.inject({ + method: 'GET', + url: '/api/segment-lifeline/S-test/replay?threadId=t&turnId=1', + headers: SESSION_HEADERS, + }); + assert.equal(res.statusCode, 503); + await app.close(); + }); + + test('returns 400 when threadId or turnId missing', async () => { + const { InjectionTraceStore } = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + const store = new InjectionTraceStore(new FakeRedis()); + const app = await buildReplayApp({ traceStore: store, threadStore: makeThreadStore() }); + + const missingThread = await app.inject({ + method: 'GET', + url: '/api/segment-lifeline/S-test/replay?turnId=1', + headers: SESSION_HEADERS, + }); + assert.equal(missingThread.statusCode, 400); + + const missingTurn = await app.inject({ + method: 'GET', + url: '/api/segment-lifeline/S-test/replay?threadId=t', + headers: SESSION_HEADERS, + }); + assert.equal(missingTurn.statusCode, 400); + + await app.close(); + }); + + test('returns 404 when replay snapshot not found', async () => { + const { InjectionTraceStore } = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + const store = new InjectionTraceStore(new FakeRedis()); + const app = await buildReplayApp({ traceStore: store, threadStore: makeThreadStore() }); + const res = await app.inject({ + method: 'GET', + url: '/api/segment-lifeline/S-test/replay?threadId=t&turnId=1', + headers: SESSION_HEADERS, + }); + assert.equal(res.statusCode, 404); + await app.close(); + }); + + test('returns 403 for cross-user thread access', async () => { + const { InjectionTraceStore } = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + const redis = new FakeRedis(); + const traceStore = new InjectionTraceStore(redis); + const messageStore = new MessageStore(); + + const snapshot = makeSnapshot({ threadId: 't', turnId: '1', segmentId: 'S-test' }); + await seedTurn(traceStore, { + threadId: snapshot.threadId, + turnId: snapshot.turnId, + catId: snapshot.catId, + timestamp: snapshot.timestamp, + }); + await traceStore.persistReplaySnapshots(snapshot.threadId, snapshot.turnId, [snapshot]); + + const app = await buildReplayApp({ traceStore, messageStore, threadStore: makeThreadStore('other-user') }); + const res = await app.inject({ + method: 'GET', + url: '/api/segment-lifeline/S-test/replay?threadId=t&turnId=1', + headers: SESSION_HEADERS, + }); + assert.equal(res.statusCode, 403); + await app.close(); + }); + + test('returns full replay payload with content, source kind, template, vars, guard events, captured messages', async () => { + const { InjectionTraceStore } = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + const { GuardRejectionEventLog } = await import('../dist/infrastructure/harness-eval/GuardRejectionEventLog.js'); + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + + const redis = new FakeRedis(); + const traceStore = new InjectionTraceStore(redis); + const guardLog = new GuardRejectionEventLog(redis); + const messageStore = new MessageStore(); + + const timestamp = 5000; + + await guardLog.append({ + eventId: 'g1', + ledgerId: 'layer/g1', + kind: 'http_rate_limit', + threadId: 't', + catId: 'opus', + guardId: 'hold_ball_rate_limit', + invocationId: 'inv-1', + sourceTool: 'hold_ball', + normalizedReason: 'rate_limited', + layer: 'api-route', + ownerUserId: 'test-user', + timestamp: timestamp + 1000, + correlationConfidence: 'window', + currentCount: 4, + maxAllowed: 3, + windowMs: 3600000, + }); + + const msg1 = messageStore.append({ + userId: 'test-user', + threadId: 't', + catId: null, + content: 'hello', + mentions: [], + timestamp: timestamp - 1000, + provenance: { author: 'user', routed: false, observation: 'original' }, + }); + const msg2 = messageStore.append({ + userId: 'test-user', + threadId: 't', + catId: 'opus', + content: 'response text', + mentions: [], + timestamp: timestamp + 500, + provenance: { author: 'cat', routed: false, observation: 'original' }, + }); + + const snapshot = makeSnapshot({ + threadId: 't', + turnId: '1', + segmentId: 'S-test', + catId: 'opus', + timestamp, + overrides: { surroundingMessageIds: [msg1.id, msg2.id] }, + }); + await seedTurn(traceStore, { + threadId: snapshot.threadId, + turnId: snapshot.turnId, + catId: snapshot.catId, + timestamp: snapshot.timestamp, + }); + await traceStore.persistReplaySnapshots(snapshot.threadId, snapshot.turnId, [snapshot]); + + const app = await buildReplayApp({ + traceStore, + guardRejectionLog: guardLog, + messageStore, + threadStore: makeThreadStore(), + }); + const res = await app.inject({ + method: 'GET', + url: '/api/segment-lifeline/S-test/replay?threadId=t&turnId=1', + headers: SESSION_HEADERS, + }); + + assert.equal(res.statusCode, 200, `expected 200, got ${res.statusCode}: ${res.body}`); + const body = JSON.parse(res.body); + + assert.equal(body.segmentId, 'S-test'); + assert.equal(body.threadId, 't'); + assert.equal(body.turnId, '1'); + assert.equal(body.catId, 'opus'); + assert.equal(body.timestamp, timestamp); + assert.equal(body.stage, 'session-init'); + assert.equal(body.pipelineStatus, 'fired'); + assert.equal(body.version, 1); + assert.equal(body.versionGap, null); + assert.equal(body.content, 'rendered content'); + assert.equal(body.contentGap, null); + assert.equal(body.contentSourceKind, 'template'); + assert.equal(body.contentSourceKindGap, null); + assert.equal(body.templateRef, 'templates/S-test.md'); + assert.equal(body.templateRefGap, null); + assert.deepEqual(body.templateVars, { VAR: 'value' }); + assert.equal(body.templateVarsGap, null); + assert.equal(body.messageAnchorId, 'anchor-1'); + assert.equal(body.messageAnchorIdGap, null); + + assert.equal(body.guardEvents.length, 1); + assert.equal(body.guardEvents[0].kind, 'http_rate_limit'); + assert.equal(body.guardEvents[0].guardId, 'hold_ball_rate_limit'); + + assert.equal(body.surroundingMessages?.length, 2); + assert.equal(body.surroundingMessagesGap, null); + assert.equal(body.surroundingMessages[0].role, 'user'); + assert.equal(body.surroundingMessages[1].role, 'assistant'); + + await app.close(); + }); + + test('passes through snapshot surroundingMessagesGap unavailable', async () => { + const { InjectionTraceStore } = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + + const traceStore = new InjectionTraceStore(new FakeRedis()); + const messageStore = new MessageStore(); + + const snapshot = makeSnapshot({ + threadId: 't', + turnId: '1', + segmentId: 'S-test', + overrides: { + surroundingMessageIds: [], + surroundingMessagesGap: 'unavailable', + }, + }); + await seedTurn(traceStore, { + threadId: snapshot.threadId, + turnId: snapshot.turnId, + catId: snapshot.catId, + timestamp: snapshot.timestamp, + }); + await traceStore.persistReplaySnapshots(snapshot.threadId, snapshot.turnId, [snapshot]); + + const app = await buildReplayApp({ traceStore, messageStore, threadStore: makeThreadStore() }); + const res = await app.inject({ + method: 'GET', + url: '/api/segment-lifeline/S-test/replay?threadId=t&turnId=1', + headers: SESSION_HEADERS, + }); + + assert.equal(res.statusCode, 200); + const body = JSON.parse(res.body); + assert.equal(body.surroundingMessages, null); + assert.equal(body.surroundingMessagesGap, 'unavailable'); + await app.close(); + }); + + test('version null is reported as legacy-missing gap', async () => { + const { InjectionTraceStore } = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + const redis = new FakeRedis(); + const traceStore = new InjectionTraceStore(redis); + + const snapshot = makeSnapshot({ + threadId: 't', + turnId: '1', + segmentId: 'S-test', + overrides: { version: null }, + }); + await seedTurn(traceStore, { + threadId: snapshot.threadId, + turnId: snapshot.turnId, + catId: snapshot.catId, + timestamp: snapshot.timestamp, + }); + await traceStore.persistReplaySnapshots(snapshot.threadId, snapshot.turnId, [snapshot]); + + const app = await buildReplayApp({ traceStore, threadStore: makeThreadStore() }); + const res = await app.inject({ + method: 'GET', + url: '/api/segment-lifeline/S-test/replay?threadId=t&turnId=1', + headers: SESSION_HEADERS, + }); + + assert.equal(res.statusCode, 200); + const body = JSON.parse(res.body); + assert.equal(body.version, null); + assert.equal(body.versionGap, 'legacy-missing'); + await app.close(); + }); + + test('native-L0 templateVars null is valid not corrupt', async () => { + const { InjectionTraceStore } = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + const redis = new FakeRedis(); + const traceStore = new InjectionTraceStore(redis); + + const snapshot = makeSnapshot({ + threadId: 't', + turnId: '1', + segmentId: 'S-test', + overrides: { + contentSourceKind: 'native-l0', + templateVars: null, + }, + }); + await seedTurn(traceStore, { + threadId: snapshot.threadId, + turnId: snapshot.turnId, + catId: snapshot.catId, + timestamp: snapshot.timestamp, + }); + await traceStore.persistReplaySnapshots(snapshot.threadId, snapshot.turnId, [snapshot]); + + const app = await buildReplayApp({ traceStore, threadStore: makeThreadStore() }); + const res = await app.inject({ + method: 'GET', + url: '/api/segment-lifeline/S-test/replay?threadId=t&turnId=1', + headers: SESSION_HEADERS, + }); + + assert.equal(res.statusCode, 200); + const body = JSON.parse(res.body); + assert.equal(body.contentSourceKind, 'native-l0'); + assert.equal(body.templateVars, null); + assert.equal(body.templateVarsGap, null); + await app.close(); + }); + + test('marks undefined fields as legacy-missing gaps', async () => { + const { InjectionTraceStore } = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + const redis = new FakeRedis(); + const traceStore = new InjectionTraceStore(redis); + + const snapshot = makeSnapshot({ + threadId: 't', + turnId: '1', + segmentId: 'S-test', + overrides: { + content: undefined, + contentSourceKind: undefined, + contentSourceRef: undefined, + templateVars: undefined, + version: undefined, + messageAnchorId: undefined, + surroundingMessageIds: undefined, + }, + }); + await seedTurn(traceStore, { + threadId: snapshot.threadId, + turnId: snapshot.turnId, + catId: snapshot.catId, + timestamp: snapshot.timestamp, + }); + await traceStore.persistReplaySnapshots(snapshot.threadId, snapshot.turnId, [snapshot]); + + const app = await buildReplayApp({ traceStore, threadStore: makeThreadStore() }); + const res = await app.inject({ + method: 'GET', + url: '/api/segment-lifeline/S-test/replay?threadId=t&turnId=1', + headers: SESSION_HEADERS, + }); + + assert.equal(res.statusCode, 200); + const body = JSON.parse(res.body); + + assert.equal(body.contentGap, 'legacy-missing'); + assert.equal(body.contentSourceKindGap, 'legacy-missing'); + assert.equal(body.templateRefGap, 'legacy-missing'); + assert.equal(body.templateVarsGap, 'legacy-missing'); + assert.equal(body.versionGap, 'legacy-missing'); + assert.equal(body.messageAnchorIdGap, 'legacy-missing'); + assert.equal(body.surroundingMessagesGap, 'legacy-missing'); + assert.equal(body.guardEventsGap, 'unavailable'); + + await app.close(); + }); + + test('marks malformed fields as invalid-present gaps', async () => { + const { InjectionTraceStore } = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + const redis = new FakeRedis(); + const traceStore = new InjectionTraceStore(redis); + + const snapshot = makeSnapshot({ + threadId: 't', + turnId: '1', + segmentId: 'S-test', + overrides: { + version: 'not-a-number', + templateVars: ['not-an-object'], + contentSourceKind: 'bogus', + messageAnchorId: 123, + surroundingMessageIds: 'not-an-array', + }, + }); + await seedTurn(traceStore, { + threadId: snapshot.threadId, + turnId: snapshot.turnId, + catId: snapshot.catId, + timestamp: snapshot.timestamp, + }); + await traceStore.persistReplaySnapshots(snapshot.threadId, snapshot.turnId, [snapshot]); + + const app = await buildReplayApp({ traceStore, threadStore: makeThreadStore() }); + const res = await app.inject({ + method: 'GET', + url: '/api/segment-lifeline/S-test/replay?threadId=t&turnId=1', + headers: SESSION_HEADERS, + }); + + assert.equal(res.statusCode, 200); + const body = JSON.parse(res.body); + + assert.equal(body.versionGap, 'invalid-present'); + assert.equal(body.templateVarsGap, 'invalid-present'); + assert.equal(body.contentSourceKindGap, 'invalid-present'); + assert.equal(body.messageAnchorIdGap, 'invalid-present'); + assert.equal(body.surroundingMessagesGap, 'invalid-present'); + + await app.close(); + }); + + test('missing surroundingMessagesGap field is reported as legacy-missing', async () => { + const { InjectionTraceStore } = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + const redis = new FakeRedis(); + const traceStore = new InjectionTraceStore(redis); + + const snapshot = makeSnapshot({ + threadId: 't', + turnId: '1', + segmentId: 'S-test', + overrides: { + surroundingMessageIds: ['m1'], + surroundingMessagesGap: undefined, + }, + }); + await seedTurn(traceStore, { + threadId: snapshot.threadId, + turnId: snapshot.turnId, + catId: snapshot.catId, + timestamp: snapshot.timestamp, + }); + await traceStore.persistReplaySnapshots(snapshot.threadId, snapshot.turnId, [snapshot]); + + const app = await buildReplayApp({ traceStore, threadStore: makeThreadStore() }); + const res = await app.inject({ + method: 'GET', + url: '/api/segment-lifeline/S-test/replay?threadId=t&turnId=1', + headers: SESSION_HEADERS, + }); + + assert.equal(res.statusCode, 200); + const body = JSON.parse(res.body); + assert.equal(body.surroundingMessages, null); + assert.equal(body.surroundingMessagesGap, 'legacy-missing'); + + await app.close(); + }); + + test('invalid surroundingMessagesGap value is reported as invalid-present', async () => { + const { InjectionTraceStore } = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + const redis = new FakeRedis(); + const traceStore = new InjectionTraceStore(redis); + + const snapshot = makeSnapshot({ + threadId: 't', + turnId: '1', + segmentId: 'S-test', + overrides: { + surroundingMessageIds: ['m1'], + surroundingMessagesGap: 'bogus-value', + }, + }); + await seedTurn(traceStore, { + threadId: snapshot.threadId, + turnId: snapshot.turnId, + catId: snapshot.catId, + timestamp: snapshot.timestamp, + }); + await traceStore.persistReplaySnapshots(snapshot.threadId, snapshot.turnId, [snapshot]); + + const app = await buildReplayApp({ traceStore, threadStore: makeThreadStore() }); + const res = await app.inject({ + method: 'GET', + url: '/api/segment-lifeline/S-test/replay?threadId=t&turnId=1', + headers: SESSION_HEADERS, + }); + + assert.equal(res.statusCode, 200); + const body = JSON.parse(res.body); + assert.equal(body.surroundingMessages, null); + assert.equal(body.surroundingMessagesGap, 'invalid-present'); + + await app.close(); + }); + + test('drops deleted messages from captured context without failing', async () => { + const { InjectionTraceStore } = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + + const traceStore = new InjectionTraceStore(new FakeRedis()); + const messageStore = new MessageStore(); + + const first = messageStore.append({ + userId: 'test-user', + threadId: 't', + catId: null, + content: 'first', + mentions: [], + timestamp: 1000, + provenance: { author: 'user', routed: false, observation: 'original' }, + }); + const second = messageStore.append({ + userId: 'test-user', + threadId: 't', + catId: 'opus', + content: 'second', + mentions: [], + timestamp: 2000, + provenance: { author: 'cat', routed: false, observation: 'original' }, + }); + + const snapshot = makeSnapshot({ + threadId: 't', + turnId: '1', + segmentId: 'S-test', + overrides: { surroundingMessageIds: [first.id, 'deleted', second.id] }, + }); + await seedTurn(traceStore, { + threadId: snapshot.threadId, + turnId: snapshot.turnId, + catId: snapshot.catId, + timestamp: snapshot.timestamp, + }); + await traceStore.persistReplaySnapshots(snapshot.threadId, snapshot.turnId, [snapshot]); + + const app = await buildReplayApp({ traceStore, messageStore, threadStore: makeThreadStore() }); + const res = await app.inject({ + method: 'GET', + url: '/api/segment-lifeline/S-test/replay?threadId=t&turnId=1', + headers: SESSION_HEADERS, + }); + + assert.equal(res.statusCode, 200); + const body = JSON.parse(res.body); + assert.equal(body.surroundingMessages?.length, 2); + assert.equal(body.surroundingMessagesGap, 'unavailable'); + + await app.close(); + }); + + test('derives role from message provenance.author', async () => { + const { InjectionTraceStore } = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + + const traceStore = new InjectionTraceStore(new FakeRedis()); + const messageStore = new MessageStore(); + + const systemMsg = messageStore.append({ + userId: 'system', + threadId: 't', + catId: null, + content: 'system notice', + mentions: [], + timestamp: 1000, + provenance: { author: 'system', routed: false, observation: 'original' }, + }); + + const snapshot = makeSnapshot({ + threadId: 't', + turnId: '1', + segmentId: 'S-test', + overrides: { surroundingMessageIds: [systemMsg.id] }, + }); + await seedTurn(traceStore, { + threadId: snapshot.threadId, + turnId: snapshot.turnId, + catId: snapshot.catId, + timestamp: snapshot.timestamp, + }); + await traceStore.persistReplaySnapshots(snapshot.threadId, snapshot.turnId, [snapshot]); + + const app = await buildReplayApp({ traceStore, messageStore, threadStore: makeThreadStore() }); + const res = await app.inject({ + method: 'GET', + url: '/api/segment-lifeline/S-test/replay?threadId=t&turnId=1', + headers: SESSION_HEADERS, + }); + + assert.equal(res.statusCode, 200); + const body = JSON.parse(res.body); + assert.equal(body.surroundingMessages?.length, 1); + assert.equal(body.surroundingMessages[0].role, 'system'); + + await app.close(); + }); + + test('persists snapshot hash atomically', async () => { + const { InjectionTraceStore } = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + const redis = new FakeRedis(); + const store = new InjectionTraceStore(redis); + const snapshot = makeSnapshot({ threadId: 't', turnId: '1', segmentId: 'S-test' }); + await seedTurn(store, { threadId: 't', turnId: '1', catId: snapshot.catId, timestamp: snapshot.timestamp }); + await store.persistReplaySnapshots(snapshot.threadId, snapshot.turnId, [snapshot]); + + const hash = redis.hashes.get('replay-snapshot:t:1'); + assert.ok(hash?.has('S-test')); + }); + + test('snapshot write is suppressed when turn has been deleted', async () => { + const { InjectionTraceStore } = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + const redis = new FakeRedis(); + const store = new InjectionTraceStore(redis); + const snapshot = makeSnapshot({ threadId: 't', turnId: '1', segmentId: 'S-test' }); + + await store.deleteTurn('t', '1'); + await store.persistReplaySnapshots(snapshot.threadId, snapshot.turnId, [snapshot]); + + assert.equal(redis.hashes.has('replay-snapshot:t:1'), false); + }); + + test('deleteTurn removes all durable replay snapshots atomically', async () => { + const { InjectionTraceStore } = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + const redis = new FakeRedis(); + const store = new InjectionTraceStore(redis); + const s1 = makeSnapshot({ threadId: 't', turnId: '1', segmentId: 'S-a' }); + const s2 = makeSnapshot({ threadId: 't', turnId: '1', segmentId: 'S-b' }); + await seedTurn(store, { threadId: 't', turnId: '1', catId: s1.catId, timestamp: s1.timestamp }); + await store.persistReplaySnapshots('t', '1', [s1, s2]); + + await store.deleteTurn('t', '1'); + + assert.equal(redis.hashes.has('replay-snapshot:t:1'), false); + }); +}); diff --git a/packages/api/test/segment-lifeline.test.js b/packages/api/test/segment-lifeline.test.js new file mode 100644 index 0000000000..996d498af2 --- /dev/null +++ b/packages/api/test/segment-lifeline.test.js @@ -0,0 +1,522 @@ +/** + * F257 Phase D — Segment lifeline route tests. + * + * Tests the read-model join: InjectionTraceStore observations filtered by + * segmentId + GuardRejectionEventLog events + HookOverrideStore state/history. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import Fastify from 'fastify'; + +// ── FakeRedis with sorted set + SET (SADD/SMEMBERS) support ── + +class FakeRedis { + constructor() { + this.kv = new Map(); + this.sorted = new Map(); + this.sets = new Map(); // key → Set for SADD/SMEMBERS + this.ttls = new Map(); + } + + async set(key, value, ...args) { + this.kv.set(key, value); + if (args[0] === 'EX' && typeof args[1] === 'number') { + this.ttls.set(key, args[1]); + } + return 'OK'; + } + + async get(key) { + return this.kv.get(key) ?? null; + } + + async del(key) { + this.kv.delete(key); + return 1; + } + + async zadd(key, score, member) { + const set = this.sorted.get(key) ?? new Map(); + set.set(member, score); + this.sorted.set(key, set); + return 1; + } + + async zcard(key) { + return this.sorted.get(key)?.size ?? 0; + } + + async zrevrange(key, start, stop) { + const set = this.sorted.get(key); + if (!set) return []; + const entries = [...set.entries()].sort((a, b) => b[1] - a[1]); + return entries.slice(start, stop + 1).map(([m]) => m); + } + + async zrangebyscore(key, min, max) { + const set = this.sorted.get(key); + if (!set) return []; + return [...set.entries()] + .filter(([, score]) => score >= min && score <= max) + .sort((a, b) => a[1] - b[1]) + .map(([m]) => m); + } + + async zrem(key, member) { + const set = this.sorted.get(key); + if (!set) return 0; + return set.delete(member) ? 1 : 0; + } + + // Redis SET commands (SADD/SMEMBERS) — used by thread registry. + // Unlike SCAN MATCH, these respect ioredis keyPrefix in production. + async sadd(key, ...members) { + const s = this.sets.get(key) ?? new Set(); + let added = 0; + for (const m of members) { + if (!s.has(m)) { + s.add(m); + added++; + } + } + this.sets.set(key, s); + return added; + } + + async smembers(key) { + const s = this.sets.get(key); + return s ? [...s] : []; + } + + // SCAN — minimal impl for backfill testing (returns all matches in one batch). + // No keyPrefix simulation: FakeRedis stores keys without prefix, matching + // the backfill code's `prefix = redis.options?.keyPrefix ?? ''` → '' path. + async scan(_cursor, ...args) { + const matchIdx = args.indexOf('MATCH'); + const pattern = matchIdx >= 0 ? args[matchIdx + 1] : '*'; + const escaped = pattern.replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&'); + const regex = new RegExp(`^${escaped.replace(/\*/g, '.*')}$`); + const allKeys = new Set([...this.kv.keys(), ...this.sorted.keys()]); + return ['0', [...allKeys].filter((k) => regex.test(k))]; + } +} + +// ── Helpers ────────────────────────────────────────────────── + +function makeSummary(threadId, turnId, timestamp, catId, segments) { + return { + turnId, + threadId, + catId, + timestamp, + segments, + delivery: [], + totalCharCount: 100, + totalTokenEstimate: 25, + totalSegmentsObserved: segments.length, + totalSegmentsAbsent: 0, + durationMs: 5, + }; +} + +function makeSegment(segmentId, opts = {}) { + return { + segmentId, + stage: 'session-init', + status: opts.status ?? 'observed', + contentHash: 'hash-1', + charCount: opts.charCount ?? 100, + tokenEstimate: 25, + version: opts.version ?? 1, + pipelineStatus: opts.pipelineStatus ?? 'fired', + }; +} + +function makeDetail(threadId, turnId) { + return { threadId, turnId, raw: '' }; +} + +// ── listTracedThreadIds tests ─────────────────────────────── + +describe('InjectionTraceStore.listTracedThreadIds', () => { + test('returns thread IDs from index keys', async () => { + const { InjectionTraceStore } = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + const redis = new FakeRedis(); + const store = new InjectionTraceStore(redis); + + const s1 = makeSummary('thread-A', 'turn-1', 1000, 'opus', [makeSegment('S-identity')]); + const s2 = makeSummary('thread-B', 'turn-2', 2000, 'codex', [makeSegment('S-rules')]); + await store.persist(s1, makeDetail('thread-A', 'turn-1')); + await store.persist(s2, makeDetail('thread-B', 'turn-2')); + + const threadIds = await store.listTracedThreadIds(); + assert.ok(threadIds.includes('thread-A'), 'should include thread-A'); + assert.ok(threadIds.includes('thread-B'), 'should include thread-B'); + assert.equal(threadIds.length, 2); + }); + + test('returns empty when no traces exist', async () => { + const { InjectionTraceStore } = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + const redis = new FakeRedis(); + const store = new InjectionTraceStore(redis); + + const threadIds = await store.listTracedThreadIds(); + assert.deepEqual(threadIds, []); + }); + + test('backfills registry from pre-existing index keys when SET is empty', async () => { + const { InjectionTraceStore } = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + const redis = new FakeRedis(); + const store = new InjectionTraceStore(redis); + + // Simulate pre-existing data: index sorted sets exist (from old persist() + // calls before registry SET was added) but registry SET is empty. + await redis.zadd('injection-trace-index:thread-old-A', 1000, 'turn-1'); + await redis.zadd('injection-trace-index:thread-old-B', 2000, 'turn-2'); + assert.equal((await redis.smembers('injection-trace-thread-registry')).length, 0); + + // listTracedThreadIds triggers lazy backfill via SCAN + const threadIds = await store.listTracedThreadIds(); + assert.ok(threadIds.includes('thread-old-A'), 'should discover thread-old-A'); + assert.ok(threadIds.includes('thread-old-B'), 'should discover thread-old-B'); + assert.equal(threadIds.length, 2); + }); + + test('backfills legacy threads even when new threads already in registry', async () => { + const { InjectionTraceStore } = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + const redis = new FakeRedis(); + const store = new InjectionTraceStore(redis); + + // Scenario: deploy Phase D → persist() fires before Console opens → + // registry has 1 new thread but legacy index keys are not yet in SET. + // (terra P1: old code skipped backfill here because registry was non-empty) + const s = makeSummary('thread-new', 'turn-1', 1000, 'opus', [makeSegment('S-identity')]); + await store.persist(s, makeDetail('thread-new', 'turn-1')); + + // Pre-existing index key NOT in registry (old data before Phase D) + await redis.zadd('injection-trace-index:thread-legacy', 500, 'turn-0'); + + const threadIds = await store.listTracedThreadIds(); + assert.ok(threadIds.includes('thread-new'), 'new thread from persist()'); + assert.ok(threadIds.includes('thread-legacy'), 'legacy thread discovered via backfill'); + }); + + test('skips backfill when marker is set (already completed)', async () => { + const { InjectionTraceStore } = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + const redis = new FakeRedis(); + const store = new InjectionTraceStore(redis); + + // Simulate: backfill already ran in a previous process (marker set) + await redis.set('injection-trace-backfill-done', '1'); + + // Legacy index key exists but backfill won't run + await redis.zadd('injection-trace-index:thread-missed', 500, 'turn-0'); + + const threadIds = await store.listTracedThreadIds(); + // Backfill skipped (marker present) — only registry entries visible + assert.ok(!threadIds.includes('thread-missed'), 'backfill skipped due to marker'); + assert.equal(threadIds.length, 0); + }); +}); + +// ── collectObservations integration (via route helper) ────── + +describe('segment-lifeline collectObservations', () => { + test('filters observations by segmentId', async () => { + const { InjectionTraceStore } = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + const redis = new FakeRedis(); + const store = new InjectionTraceStore(redis); + + // Two traces in one thread: S-identity (our target) and S-rules (different) + const s1 = makeSummary('thread-A', 'turn-1', 5000, 'opus', [makeSegment('S-identity'), makeSegment('S-rules')]); + const s2 = makeSummary('thread-A', 'turn-2', 6000, 'codex', [makeSegment('S-rules')]); + await store.persist(s1, makeDetail('thread-A', 'turn-1')); + await store.persist(s2, makeDetail('thread-A', 'turn-2')); + + // Query window [4000, 7000) + const summaries = await store.queryWindow('thread-A', 4000, 7000); + assert.equal(summaries.length, 2, 'should have 2 summaries'); + + // Filter for S-identity + const observations = summaries + .filter((summary) => summary.segments.some((seg) => seg.segmentId === 'S-identity' && seg.status === 'observed')) + .map((summary) => ({ + threadId: summary.threadId, + turnId: summary.turnId, + timestamp: summary.timestamp, + catId: summary.catId, + })); + + assert.equal(observations.length, 1, 'only 1 trace has S-identity'); + assert.equal(observations[0].turnId, 'turn-1'); + assert.equal(observations[0].catId, 'opus'); + }); + + test('cross-thread observations merge correctly', async () => { + const { InjectionTraceStore } = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + const redis = new FakeRedis(); + const store = new InjectionTraceStore(redis); + + // Same segment in two different threads + await store.persist( + makeSummary('thread-A', 'turn-1', 5000, 'opus', [makeSegment('S-identity')]), + makeDetail('thread-A', 'turn-1'), + ); + await store.persist( + makeSummary('thread-B', 'turn-2', 6000, 'codex', [makeSegment('S-identity', { version: 2 })]), + makeDetail('thread-B', 'turn-2'), + ); + + const threadIds = await store.listTracedThreadIds(); + assert.equal(threadIds.length, 2); + + // Query both threads + const allObservations = []; + for (const threadId of threadIds) { + const summaries = await store.queryWindow(threadId, 4000, 7000); + for (const summary of summaries) { + const seg = summary.segments.find((s) => s.segmentId === 'S-identity' && s.status === 'observed'); + if (seg) { + allObservations.push({ + threadId: summary.threadId, + turnId: summary.turnId, + timestamp: summary.timestamp, + version: seg.version, + }); + } + } + } + + assert.equal(allObservations.length, 2, 'found in both threads'); + const versions = allObservations.map((o) => o.version); + assert.ok(versions.includes(1), 'v1 from thread-A'); + assert.ok(versions.includes(2), 'v2 from thread-B'); + }); + + test('absent segments excluded from observations', async () => { + const { InjectionTraceStore } = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + const redis = new FakeRedis(); + const store = new InjectionTraceStore(redis); + + await store.persist( + makeSummary('thread-A', 'turn-1', 5000, 'opus', [makeSegment('S-identity', { status: 'absent' })]), + makeDetail('thread-A', 'turn-1'), + ); + + const summaries = await store.queryWindow('thread-A', 4000, 7000); + const observed = summaries.flatMap((s) => + s.segments.filter((seg) => seg.segmentId === 'S-identity' && seg.status === 'observed'), + ); + assert.equal(observed.length, 0, 'absent segments excluded'); + }); +}); + +// ── Status derivation ─────────────────────────────────────── + +describe('segment-lifeline status derivation', () => { + test('idle when no observations', () => { + const observations = []; + const status = observations.length > 0 ? 'tracing' : 'idle'; + assert.equal(status, 'idle'); + }); + + test('tracing when observations exist', () => { + const observations = [{ version: 1 }]; + const status = observations.length > 0 ? 'tracing' : 'idle'; + assert.equal(status, 'tracing'); + }); + + test('derives latest version from observations (most recent first)', () => { + const observations = [ + { version: 2, timestamp: 6000 }, + { version: 1, timestamp: 5000 }, + ]; + // Sorted by timestamp descending, first non-null version is latest + const sorted = [...observations].sort((a, b) => b.timestamp - a.timestamp); + const latestVersion = sorted.find((o) => o.version != null)?.version ?? null; + assert.equal(latestVersion, 2); + }); + + test('null version when no observations have version', () => { + const observations = [{ version: null }]; + const latestVersion = observations.find((o) => o.version != null)?.version ?? null; + assert.equal(latestVersion, null); + }); +}); + +// ── P2-1: windowMs validation ───────────────────────────────── + +describe('segment-lifeline windowMs validation', () => { + // Extract the same validation logic used in the route + function parseWindowMs(raw) { + const DEFAULT = 7 * 24 * 60 * 60 * 1000; + const MAX = 30 * 24 * 60 * 60 * 1000; + if (raw === undefined) return { ok: true, value: DEFAULT }; + const n = Number(raw); + if (!Number.isFinite(n) || n <= 0) return { ok: false }; + return { ok: true, value: Math.min(n, MAX) }; + } + + test('rejects Infinity', () => { + assert.equal(parseWindowMs('Infinity').ok, false); + }); + + test('rejects negative', () => { + assert.equal(parseWindowMs('-5000').ok, false); + }); + + test('rejects NaN', () => { + assert.equal(parseWindowMs('abc').ok, false); + }); + + test('rejects zero', () => { + assert.equal(parseWindowMs('0').ok, false); + }); + + test('caps at 30 days', () => { + const thirtyOneDays = 31 * 24 * 60 * 60 * 1000; + const thirtyDays = 30 * 24 * 60 * 60 * 1000; + const result = parseWindowMs(String(thirtyOneDays)); + assert.equal(result.ok, true); + assert.equal(result.value, thirtyDays); + }); + + test('accepts valid positive number', () => { + const result = parseWindowMs('3600000'); + assert.equal(result.ok, true); + assert.equal(result.value, 3600000); + }); + + test('defaults when undefined', () => { + const result = parseWindowMs(undefined); + assert.equal(result.ok, true); + assert.equal(result.value, 7 * 24 * 60 * 60 * 1000); + }); +}); + +// ── P2-2: guard event three-key filtering (threadId + catId + ±120s) ── + +describe('segment-lifeline guard event filtering', () => { + const PROXIMITY_MS = 120_000; + + // Helper: match logic mirrors collectGuardEvents in segment-lifeline.ts + function filterGuardEvents(events, observations) { + return events.filter((e) => + observations.some( + (obs) => + obs.threadId === e.threadId && obs.catId === e.catId && Math.abs(obs.timestamp - e.timestamp) <= PROXIMITY_MS, + ), + ); + } + + test('same thread+cat within ±120s passes', () => { + const obs = [{ threadId: 'thread-A', catId: 'opus', timestamp: 5000 }]; + const events = [{ eventId: 'g1', threadId: 'thread-A', catId: 'opus', timestamp: 5100 }]; + assert.equal(filterGuardEvents(events, obs).length, 1); + }); + + test('same thread, different cat excluded', () => { + const obs = [{ threadId: 'thread-A', catId: 'opus', timestamp: 5000 }]; + const events = [{ eventId: 'g1', threadId: 'thread-A', catId: 'codex', timestamp: 5000 }]; + assert.equal(filterGuardEvents(events, obs).length, 0, 'different catId'); + }); + + test('same thread+cat but outside ±120s excluded', () => { + const obs = [{ threadId: 'thread-A', catId: 'opus', timestamp: 5000 }]; + const events = [{ eventId: 'g1', threadId: 'thread-A', catId: 'opus', timestamp: 5000 + PROXIMITY_MS + 1 }]; + assert.equal(filterGuardEvents(events, obs).length, 0, 'outside window'); + }); + + test('different thread excluded even if cat+time match', () => { + const obs = [{ threadId: 'thread-A', catId: 'opus', timestamp: 5000 }]; + const events = [{ eventId: 'g1', threadId: 'thread-B', catId: 'opus', timestamp: 5000 }]; + assert.equal(filterGuardEvents(events, obs).length, 0, 'different thread'); + }); + + test('no guard events when segment has no observations', () => { + const events = [{ eventId: 'g1', threadId: 'thread-A', catId: 'opus', timestamp: 5000 }]; + assert.equal(filterGuardEvents(events, []).length, 0); + }); + + test('boundary: exactly ±120s passes', () => { + const obs = [{ threadId: 'thread-A', catId: 'opus', timestamp: 5000 }]; + const events = [ + { eventId: 'g1', threadId: 'thread-A', catId: 'opus', timestamp: 5000 + PROXIMITY_MS }, + { eventId: 'g2', threadId: 'thread-A', catId: 'opus', timestamp: 5000 - PROXIMITY_MS }, + ]; + assert.equal(filterGuardEvents(events, obs).length, 2, 'boundary inclusive'); + }); +}); + +// ── R16 route-level regression: epochGuardMetrics in JSON response ── + +describe('segment-lifeline route: epochGuardMetrics in response (R16 P2-1)', () => { + const SESSION_HEADERS = { 'x-test-session-user': 'test-user' }; + + async function buildLifelineApp(traceStore, opts = {}) { + const { segmentLifelineRoutes } = await import('../dist/routes/segment-lifeline.js'); + const app = Fastify({ logger: false }); + app.addHook('preHandler', async (request) => { + const sessionUser = request.headers['x-test-session-user']; + if (typeof sessionUser === 'string' && sessionUser.trim()) { + request.sessionUserId = sessionUser.trim(); + } + }); + await app.register(segmentLifelineRoutes, { traceStore, ...opts }); + await app.ready(); + return app; + } + + test('response JSON contains epochGuardMetrics keyed by version', async () => { + const { InjectionTraceStore } = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + const redis = new FakeRedis(); + const store = new InjectionTraceStore(redis); + + // Seed an observation so the chain has tracing data + const now = Date.now(); + const s = makeSummary('thread-X', 'turn-1', now - 1000, 'opus', [makeSegment('S-test')]); + await store.persist(s, makeDetail('thread-X', 'turn-1')); + + const app = await buildLifelineApp(store); + const res = await app.inject({ + method: 'GET', + url: '/api/segment-lifeline/S-test', + headers: SESSION_HEADERS, + }); + + assert.equal(res.statusCode, 200, `expected 200, got ${res.statusCode}: ${res.body}`); + const body = JSON.parse(res.body); + + // Core contract: epochGuardMetrics must be present and keyed by version number + assert.ok('epochGuardMetrics' in body, 'response must include epochGuardMetrics'); + assert.equal(typeof body.epochGuardMetrics, 'object', 'epochGuardMetrics is an object'); + + // v1 (manifest baseline) must have an entry (empty array since no guard events) + assert.ok('1' in body.epochGuardMetrics, 'epochGuardMetrics has v1 key'); + assert.ok(Array.isArray(body.epochGuardMetrics['1']), 'v1 value is an array'); + + // Verify other shared-contract fields are present + assert.equal(body.segmentId, 'S-test'); + assert.ok('chain' in body); + assert.ok('activeVersion' in body); + assert.ok('currentStatus' in body); + assert.ok('window' in body); + + await app.close(); + }); + + test('returns 401 without session', async () => { + const { InjectionTraceStore } = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + const redis = new FakeRedis(); + const store = new InjectionTraceStore(redis); + + const app = await buildLifelineApp(store); + const res = await app.inject({ + method: 'GET', + url: '/api/segment-lifeline/S-test', + }); + assert.equal(res.statusCode, 401); + await app.close(); + }); +}); diff --git a/packages/mcp-server/src/server-toolsets.ts b/packages/mcp-server/src/server-toolsets.ts index 02a928dba8..5b0c525fb8 100644 --- a/packages/mcp-server/src/server-toolsets.ts +++ b/packages/mcp-server/src/server-toolsets.ts @@ -18,9 +18,11 @@ import { hubActionTools, libraryLifecycleTools, limbTools, + listObjectivesTools, perspectiveTools, publishVerdictTools, recentTools, + reportHarnessSignalTools, richBlockRulesTools, scheduleTools, sessionChainTools, @@ -52,6 +54,7 @@ export const READONLY_ALLOWED_TOOLS = new Set([ 'cat_cafe_graph_resolve', // F188 Phase F AC-F1 'cat_cafe_list_recent', // F188 Phase F AC-F2 'cat_cafe_get_rich_block_rules', + 'cat_cafe_list_objectives', // F257 #3: objective registry discovery 'cat_cafe_read_file_slice', // Session chain (read-only API calls, no callback creds needed) 'cat_cafe_list_session_chain', @@ -99,6 +102,8 @@ export const AGENT_KEY_TOOLS = new Set([ 'cat_cafe_get_message', // F192 Phase H AC-H4 (砚砚 R9 P1): shared-MCP cats can publish verdicts. 'cat_cafe_publish_verdict', + // F257 V1: shared-MCP cats are first-class manual_observation reporters (T-C 三源). + 'cat_cafe_report_harness_signal', ]); /** @@ -196,7 +201,10 @@ const COLLAB_TOOL_SOURCES: readonly ToolDef[] = [ ...hubActionTools, ...eventMemoryTools, // F227: cat_cafe_teleport ...publishVerdictTools, // F192 Phase H AC-H4 + ...reportHarnessSignalTools, // F257 V1 (T-C §3.6) ...richBlockRulesTools, + ...listObjectivesTools, // F257 #3: objective registry discovery + ...gameActionTools, ...scheduleTools, ...shellTools, @@ -333,6 +341,7 @@ export const EXPLICIT_TOOL_ANNOTATIONS: Record = { cat_cafe_list_external_runtime_sessions: A_READ_LOCAL, cat_cafe_read_external_runtime_session: A_READ_LOCAL, cat_cafe_get_rich_block_rules: A_READ_LOCAL, + cat_cafe_list_objectives: A_READ_LOCAL, // F257 #3: objective registry discovery cat_cafe_run_perspective: A_READ_LOCAL, // search_evidence hits remote/external knowledge stores → openWorld cat_cafe_search_evidence: A_READ_OPEN_WORLD, @@ -381,6 +390,7 @@ export const EXPLICIT_TOOL_ANNOTATIONS: Record = { cat_cafe_propose_session_handoff: A_WRITE_SAFE, cat_cafe_propose_profile_update: A_WRITE_SAFE, cat_cafe_publish_verdict: A_WRITE_SAFE, + cat_cafe_report_harness_signal: A_WRITE_SAFE, // F257 V1: await-append to local deviation ledger (T-C) cat_cafe_register_pr_tracking: A_WRITE_SAFE, cat_cafe_register_issue_tracking: A_WRITE_SAFE, cat_cafe_get_thread_metadata: A_READ_LOCAL, diff --git a/packages/mcp-server/src/tools/callback-outbox.ts b/packages/mcp-server/src/tools/callback-outbox.ts index a0b79a1173..d624bd79b6 100644 --- a/packages/mcp-server/src/tools/callback-outbox.ts +++ b/packages/mcp-server/src/tools/callback-outbox.ts @@ -176,6 +176,11 @@ export async function sendCallbackRequest( const enableOutbox = options?.enableOutbox === true && isOutboxEnabled(); if (enableOutbox) await flushOutbox(); + // 砚砚 2026-06-17 P1: allow per-call retry-delays + timeout override. Publish- + // verdict passes `[]` (single attempt, no retry) because the route is long + + // side-effectful: auto-retry fires overlapping server-side publishes that race + // on the same branch name. Idempotency guards (verdict_already_exists) are the + // safety net for "did it publish", not client retries. const retryDelaysMs = options?.retryDelaysMs ?? getRetryDelaysMs(); const payload = JSON.stringify(request.body); const result = await postJsonWithRetry(`${request.apiUrl}${request.path}`, payload, retryDelaysMs, request.headers, { diff --git a/packages/mcp-server/src/tools/callback-tools.ts b/packages/mcp-server/src/tools/callback-tools.ts index 5b329b3962..cad9c99bac 100644 --- a/packages/mcp-server/src/tools/callback-tools.ts +++ b/packages/mcp-server/src/tools/callback-tools.ts @@ -28,6 +28,7 @@ import { formatSuggestedCrossPostActionLines } from './cross-post-suggestion-for import { withDegradation } from './degradation.js'; import type { ToolResult } from './file-tools.js'; import { errorResult, successResult } from './file-tools.js'; +import { reportGuardRejection } from './guard-rejection-report.js'; /** * F174 Phase A — reason taxonomy lives in @cat-cafe/shared (single source of @@ -195,12 +196,15 @@ export function formatCatRoutingErrorPrefix(body: { catId?: string; mention?: string; alternatives?: Array<{ mention: string; displayName?: string }>; + /** F257 #1: mention_ambiguous carries holders as `candidates` */ + candidates?: Array<{ mention: string; displayName?: string }>; }): string { const target = body.catId ? `@${body.catId}` : (body.mention ?? 'unknown'); let msg = `Cat routing failed [kind=${body.kind}] target=${target}`; if (body.kind === 'cat_disabled') msg += ' disabled.'; else if (body.kind === 'cat_not_found') msg += ' not found.'; - const alts = body.alternatives + else if (body.kind === 'mention_ambiguous') msg += ' matches MULTIPLE cats — retry with an explicit handle.'; + const alts = (body.alternatives ?? body.candidates) ?.slice(0, 3) .map((a) => `${a.mention}${a.displayName ? ` (${a.displayName})` : ''}`) .join(', '); @@ -215,8 +219,12 @@ export async function callbackPost( enableOutbox?: boolean; agentKeyCatId?: string; forceAgentKey?: boolean; - retryDelaysMs?: number[]; + // 砚砚 2026-06-17 P1: per-call overrides for long, side-effectful routes + // (cat_cafe_publish_verdict). fetchTimeoutMs widens the per-attempt abort + // bound; retryDelaysMs=[] disables auto-retry so the route is not POSTed + // concurrently (overlapping publishes race on the same branch). fetchTimeoutMs?: number; + retryDelaysMs?: number[]; }, ): Promise { const config = getCallbackConfig({ @@ -234,8 +242,8 @@ export async function callbackPost( }, { enableOutbox: options?.enableOutbox === true, - retryDelaysMs: options?.retryDelaysMs, - fetchTimeoutMs: options?.fetchTimeoutMs, + ...(options?.fetchTimeoutMs !== undefined ? { fetchTimeoutMs: options.fetchTimeoutMs } : {}), + ...(options?.retryDelaysMs !== undefined ? { retryDelaysMs: options.retryDelaysMs } : {}), }, ); if (result.ok) return successResult(JSON.stringify(result.data)); @@ -246,7 +254,7 @@ export async function callbackPost( if (match400) { try { const parsed = JSON.parse(match400[1]) as { kind?: unknown }; - if (parsed.kind === 'cat_disabled' || parsed.kind === 'cat_not_found') { + if (parsed.kind === 'cat_disabled' || parsed.kind === 'cat_not_found' || parsed.kind === 'mention_ambiguous') { const prefix = formatCatRoutingErrorPrefix(parsed as Parameters[0]); return errorResult(`${prefix}\n${match400[1]}`); } @@ -1040,10 +1048,33 @@ export async function handleCrossPostMessage(input: { // ergonomics + closing the agent-key API-layer gap. const hasLineStartMention = hasPlausibleLineStartMention(input.content); if (!hasTargetCats && !hasLineStartMention) { + // F257 V2 (AC-B1 dual entry): this rejection happens client-locally and + // never reaches an API route — report it to the harness ledger so the + // pot's firing is visible. Fire-and-forget, fail-open: reporting never + // affects the error the cat sees. Callers without a resolvable config + // are skipped (config null → nothing to report against). + const guardTransportConfig = getCallbackConfig( + input.agentKeyCatId ? { agentKeyCatId: input.agentKeyCatId } : undefined, + ); + if (guardTransportConfig) { + reportGuardRejection( + { apiUrl: guardTransportConfig.apiUrl, headers: buildAuthHeaders(guardTransportConfig) }, + { + kind: 'http_policy_reject', + guardId: 'cross_post_routing_credentials', + sourceTool: 'cross_post_message', + normalizedReason: 'no_routing_credentials', + // Agent-key callers have no principal thread binding — pass the + // target-thread coordinate for server-side scoped verification. + threadId: input.threadId, + }, + ); + } return errorResult( 'cross_post_message requires routing credentials (F193 AC-A4). ' + 'Pass targetCats: ["catHandle"] OR add a line-start @catHandle in content. ' + - 'Without routing, the cross-thread message would land in the target thread but trigger no cat session.', + 'Without routing, the cross-thread message would land in the target thread but trigger no cat session. ' + + '[ledger: mcp/cross-post-routing-credentials]', ); } // cross_post_message is the legitimate cross-thread tool — bypass @@ -1966,13 +1997,23 @@ export async function handleHoldBall(input: { isError: true, }; } - const result = await callbackPost('/api/callbacks/hold-ball', { - reason: input.reason, - nextStep: input.nextStep, - ...(hasWakeAfter ? { wakeAfterMs: input.wakeAfterMs } : {}), - ...(hasWakeWhen ? { wakeWhen: input.wakeWhen } : {}), - ...(input.waitSourceRef ? { waitSourceRef: input.waitSourceRef } : {}), - }); + // F257 fix (verdict PR #39): disable auto-retry for hold_ball. + // hold_ball 429 means "MAX_HOLDS_PER_WINDOW (3/h) reached" — retrying in + // 1s/2s/4s will never succeed (window is 1 hour). The default retry policy + // treated 429 as retryable, causing 3 identical POSTs that each emitted a + // GuardRejectionEvent, triggering a false threshold escalation. + // Prior art: publish-verdict also passes retryDelaysMs=[] (砚砚 2026-06-17). + const result = await callbackPost( + '/api/callbacks/hold-ball', + { + reason: input.reason, + nextStep: input.nextStep, + ...(hasWakeAfter ? { wakeAfterMs: input.wakeAfterMs } : {}), + ...(hasWakeWhen ? { wakeWhen: input.wakeWhen } : {}), + ...(input.waitSourceRef ? { waitSourceRef: input.waitSourceRef } : {}), + }, + { retryDelaysMs: [] }, + ); // F254 B2: Check for unresolved freshness notices after successful hold_ball. // If the cat has unacknowledged notices, append a reminder to the result. diff --git a/packages/mcp-server/src/tools/guard-rejection-report.ts b/packages/mcp-server/src/tools/guard-rejection-report.ts new file mode 100644 index 0000000000..1118bb6764 --- /dev/null +++ b/packages/mcp-server/src/tools/guard-rejection-report.ts @@ -0,0 +1,51 @@ +/** + * F257 V2/Phase B — fire-and-forget guard rejection reporting (MCP client layer). + * + * MCP-local fail-closed rejections never reach an API route; this channel + * makes them visible to the harness ledger (spec AC-B1 dual-entry: API route + * layer AND MCP client layer must both emit). + * + * Fail-open contract: reporting must NEVER affect the tool result the cat + * sees — fire-and-forget, all errors swallowed, nothing awaited on the tool + * path. The server side (POST /api/callbacks/guard-rejections) derives + * catId/threadId/invocationId from the auth headers, so this module only + * sends guard semantics. + * + * Zero imports from callback-tools (the caller passes apiUrl + auth headers) + * to keep the dependency one-directional: callback-tools → this module. + */ + +export interface GuardRejectionTransport { + /** Callback API base url (CallbackConfig.apiUrl). */ + apiUrl: string; + /** Auth headers from buildAuthHeaders(config). */ + headers: Record; +} + +export interface GuardRejectionReport { + kind: 'http_schema_reject' | 'http_policy_reject'; + guardId: string; + sourceTool: string; + normalizedReason: string; + /** + * Thread coordinate for agent-key callers (no thread binding in their + * principal). Server-side it is VERIFIED via the scoped-thread resolver, + * never trusted as-is; invocation principals ignore it entirely. + */ + threadId?: string; +} + +/** Fire-and-forget; never throws, never blocks the tool path. */ +export function reportGuardRejection(transport: GuardRejectionTransport, report: GuardRejectionReport): void { + try { + void fetch(`${transport.apiUrl}/api/callbacks/guard-rejections`, { + method: 'POST', + headers: { ...transport.headers, 'content-type': 'application/json' }, + body: JSON.stringify(report), + }).catch(() => { + /* fail-open — observation must not affect the business path */ + }); + } catch { + /* fail-open — even synchronous fetch setup errors are swallowed */ + } +} diff --git a/packages/mcp-server/src/tools/index.ts b/packages/mcp-server/src/tools/index.ts index b2807c635f..a8de8ce834 100644 --- a/packages/mcp-server/src/tools/index.ts +++ b/packages/mcp-server/src/tools/index.ts @@ -141,6 +141,12 @@ export { limbListToolsInputSchema, limbTools, } from './limb-tools.js'; +// F257 #3: cat_cafe_list_objectives (objective registry discovery) +export { + handleListObjectives, + listObjectivesInputSchema, + listObjectivesTools, +} from './list-objectives-tool.js'; export { handleRunPerspective, perspectiveTools, @@ -158,6 +164,12 @@ export { listRecentInputSchema, recentTools, } from './recent-tools.js'; +// F257 V1: cat_cafe_report_harness_signal (T-C §3.6) +export { + handleReportHarnessSignalTool, + reportHarnessSignalInputSchema, + reportHarnessSignalTools, +} from './report-harness-signal-tool.js'; // F193 Phase D AC-D1: reflect-tools removed (deprecated) export { handleGetRichBlockRules, diff --git a/packages/mcp-server/src/tools/list-objectives-tool.ts b/packages/mcp-server/src/tools/list-objectives-tool.ts new file mode 100644 index 0000000000..568e4fd8bb --- /dev/null +++ b/packages/mcp-server/src/tools/list-objectives-tool.ts @@ -0,0 +1,57 @@ +/** + * F257 修复清单 #3 — List Objectives Tool + * MCP 工具: 只读发现 report_harness_signal 可用的 objectiveId,取代"三次上报三次考古"。 + */ + +import type { ToolResult } from './file-tools.js'; +import { errorResult, successResult } from './file-tools.js'; + +const API_URL = process.env.CAT_CAFE_API_URL ?? 'http://localhost:3004'; + +interface ObjectiveDefinition { + id: string; + statement: string; +} + +export async function handleListObjectives(): Promise { + const url = `${API_URL}/api/callbacks/objectives`; + + try { + const response = await fetch(url); + + if (!response.ok) { + const text = await response.text(); + return errorResult(`Failed to fetch objectives (${response.status}): ${text}`); + } + + const data = (await response.json()) as { objectives?: ObjectiveDefinition[] }; + const objectives = data.objectives ?? []; + if (objectives.length === 0) { + // API fail-closes (503) on unreadable/malformed/invalid registry — a 200 with + // an empty list is therefore a genuinely empty (but valid) catalog, not a + // masked failure (2a R1 P1-2). + return successResult('No objectives registered yet.'); + } + const lines = objectives.map((o) => `- ${o.id} — ${o.statement}`); + return successResult( + `Valid objectiveIds for cat_cafe_report_harness_signal (pick one; do not invent):\n${lines.join('\n')}`, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return errorResult(`List objectives request failed: ${message}`); + } +} + +export const listObjectivesInputSchema = {}; + +export const listObjectivesTools = [ + { + name: 'cat_cafe_list_objectives', + description: + 'F257: list the registered objectives (id + statement) that cat_cafe_report_harness_signal accepts as objectiveId. ' + + 'Call this BEFORE report_harness_signal to pick a valid objectiveId instead of guessing — no more archaeology. ' + + 'Read-only; the set grows as objectives are canonized.', + inputSchema: listObjectivesInputSchema, + handler: handleListObjectives, + }, +] as const; diff --git a/packages/mcp-server/src/tools/publish-verdict-tool.ts b/packages/mcp-server/src/tools/publish-verdict-tool.ts index fed2bf3913..dd8d074c28 100644 --- a/packages/mcp-server/src/tools/publish-verdict-tool.ts +++ b/packages/mcp-server/src/tools/publish-verdict-tool.ts @@ -2,16 +2,14 @@ import { z } from 'zod'; import { callbackPost } from './callback-tools.js'; import type { ToolResult } from './file-tools.js'; -const PUBLISH_VERDICT_FETCH_TIMEOUT_MS = 120_000; - /** * F192 Phase H AC-H4: cat_cafe_publish_verdict MCP tool. * * 砚砚 R3 P1 #1 cloud: previously DOMAIN_INSTRUCTIONS referenced this tool but * it wasn't registered anywhere — cats would loop. Now wired to * POST /api/eval-domains/:domainId/publish-verdict which calls - * handlePublishVerdict (validates packet → resolves sourceRefs → invokes - * isolated-worktree publisher → opens auto-PR). + * handlePublishVerdict (validates packet → resolves sourceRefs → invokes the + * durable ArtifactPublisher outside the product Git checkout). * * F192 Phase H 收尾 PR-2 (砚砚 R1 Q3): sourceRefs is now a discriminated union * supporting eval:a2a (snapshot/attribution YAML basenames), @@ -265,6 +263,51 @@ const anchorTelemetrySourceRefsShape = z }) .describe('eval:anchor-first sourceRefs — replayable anchor telemetry rollup window selector.'); +/** + * F257 Phase A Line B — prompt-segments sourceRefs. Replayable guard rejection + * event window selector: provider resolves to GuardRejectionEventLog query. + * + * KEEP IN SYNC: packages/api/.../publish-verdict/types.ts PromptSegmentsSourceSelector + * + packages/api/.../publish-verdict/validation.ts validatePromptSegmentsSelector. + */ +const promptSegmentsSourceRefsShape = z + .object({ + kind: z.literal('prompt-segments'), + windowStartMs: z.number().finite().describe('Inclusive epoch ms window start for guard rejection events.'), + windowEndMs: z + .number() + .finite() + .describe('Exclusive epoch ms window end for guard rejection events. Must be > windowStartMs.'), + evalRunId: z + .string() + .min(1) + .regex(/^hlr-\d+-[a-f0-9]{8}$/, 'evalRunId must match generator format: hlr--') + .describe( + 'KD-17 snapshot-first: run ID from the pre-computed snapshot. Copy the exact evalRunId from your invocation message. Generator reads the stored snapshot by this ID (fail-closed on missing).', + ), + }) + .describe('eval:harness-ledger sourceRefs — replayable prompt-segments guard rejection window selector.'); + +/** + * F253 Phase C — qc-metrics-rollup sourceRefs. Replayable QC metrics + * window selector: provider resolves via resolveQcMetrics to a zero-baseline + * QcMetricsSnapshot (Phase C) or live rollup (future phases). Generator + * writes rollup snapshot + verdict into bundle. + * + * KEEP IN SYNC: packages/api/src/infrastructure/harness-eval/qc-metrics-provider.ts QcMetricsSelector + * + packages/api/src/infrastructure/harness-eval/publish-verdict/validation.ts validateQcMetricsSelector. + */ +const qcMetricsSourceRefsShape = z + .object({ + kind: z.literal('qc-metrics-rollup'), + windowStartMs: z.number().finite().describe('Inclusive epoch ms window start for QC metrics rollup.'), + windowEndMs: z + .number() + .finite() + .describe('Exclusive epoch ms window end for QC metrics rollup. Must be > windowStartMs.'), + }) + .describe('eval:qc sourceRefs — replayable QC metrics rollup window selector.'); + const sourceRefsShape = z .union([ a2aSourceRefsShape, @@ -274,9 +317,11 @@ const sourceRefsShape = z sopSourceRefsShape, frictionRollupSourceRefsShape, anchorTelemetrySourceRefsShape, + promptSegmentsSourceRefsShape, + qcMetricsSourceRefsShape, ]) .describe( - 'Discriminated union by `kind` field. a2a kind is default (backward compat); capability-wakeup-trial-window kind wired in PR-2; memory-recall-snapshot kind wired in F192 memory wire-up; task-outcome-snapshot kind wired in task-outcome PR; sop-trace-eval kind wired in F192 sop-wiring; friction-rollup-snapshot kind wired in F245 PR1b; anchor-telemetry-snapshot kind wired in F236 Track-2.', + 'Discriminated union by `kind` field. a2a kind is default (backward compat); capability-wakeup-trial-window kind wired in PR-2; memory-recall-snapshot kind wired in F192 memory wire-up; task-outcome-snapshot kind wired in task-outcome PR; sop-trace-eval kind wired in F192 sop-wiring; friction-rollup-snapshot kind wired in F245 PR1b; anchor-telemetry-snapshot kind wired in F236 Track-2; prompt-segments kind wired in F257 Phase A Line B; qc-metrics-rollup kind wired in F253 Phase C.', ); export const publishVerdictInputSchema = { @@ -348,10 +393,28 @@ type PublishVerdictToolInput = { kind: 'anchor-telemetry-snapshot'; windowStartMs: number; windowEndMs: number; + } + | { + kind: 'prompt-segments'; + windowStartMs: number; + windowEndMs: number; + evalRunId: string; + } + | { + kind: 'qc-metrics-rollup'; + windowStartMs: number; + windowEndMs: number; }; agentKeyCatId?: string | undefined; }; +// Artifact publication may include evidence replay plus a transactional +// afterPublish side effect. The default 10s-per-attempt retry policy could abort +// before the route returns and start overlapping publications for one verdict ID. +// Give this call one long attempt with no client retry; the server's atomic +// artifact-id guard is the idempotency boundary. +const PUBLISH_VERDICT_FETCH_TIMEOUT_MS = 180_000; + export async function handlePublishVerdict(input: PublishVerdictToolInput): Promise { return callbackPost( `/api/eval-domains/${encodeURIComponent(input.domainId)}/publish-verdict`, @@ -373,13 +436,13 @@ export const publishVerdictTools = [ { name: 'cat_cafe_publish_verdict', description: - 'F192 Phase H: publish your eval verdict as a structured commit + auto-PR. ' + + 'F192/F257: publish your eval verdict as a durable runtime artifact outside the product Git repository. ' + 'Use after your analysis converges to a verdict for your assigned eval domain. ' + 'Pass the complete VerdictHandoffPacket + sourceRefs (shape depends on your domain — see your eval cat invocation instructions for the exact selector shape). ' + - 'The handler validates schema, dispatches to the per-domain generator inside an isolated git worktree, commits + pushes the branch verdict/auto//, and opens an auto-PR. Returns { commitSha, prUrl }. ' + - 'GOTCHA: wired domains: eval:a2a (snapshot/attribution YAML basenames) + eval:capability-wakeup (replayable trial-window selector) + eval:memory (memory-recall-snapshot selector) + eval:sop (sop-trace-eval replayable SOP trace selector) + eval:task-outcome (task-outcome-snapshot replay window) + eval:friction (friction-rollup-snapshot replay window) + eval:anchor-first (anchor-telemetry-snapshot rollup window). Unregistered domains return 501. ' + + 'The handler validates schema, dispatches to the per-domain generator in a temporary artifact staging root, and atomically publishes to the configured durable artifact store. Returns { artifactId, artifactUrl, verdictPath, bundleDir }. ' + + 'GOTCHA: wired domains: eval:a2a (snapshot/attribution YAML basenames) + eval:capability-wakeup (replayable trial-window selector) + eval:memory (memory-recall-snapshot selector) + eval:sop (sop-trace-eval replayable SOP trace selector) + eval:task-outcome (task-outcome-snapshot replay window) + eval:friction (friction-rollup-snapshot replay window) + eval:anchor-first (anchor-telemetry-snapshot rollup window) + eval:qc (qc-metrics-rollup window selector). Unregistered domains return 501. ' + 'GOTCHA: catId must match the registered eval cat for the domain (or its OQ-20 Redis override); 403 not_allowed otherwise. ' + - 'GOTCHA: DO NOT run git push/commit/add yourself; this tool owns the publish lifecycle.', + 'GOTCHA: runtime verdict evidence must not be committed, pushed, or opened as a Git PR. Use the returned artifact URL for traceability and handoff.', inputSchema: publishVerdictInputSchema, handler: handlePublishVerdict, }, diff --git a/packages/mcp-server/src/tools/report-harness-signal-tool.ts b/packages/mcp-server/src/tools/report-harness-signal-tool.ts new file mode 100644 index 0000000000..aa6f9393bf --- /dev/null +++ b/packages/mcp-server/src/tools/report-harness-signal-tool.ts @@ -0,0 +1,122 @@ +import { z } from 'zod'; +import { callbackPost } from './callback-tools.js'; +import type { ToolResult } from './file-tools.js'; + +/** + * F257 V1: cat_cafe_report_harness_signal — manual_observation 写入支 + * (redesign §4.8② 语义上报层). + * + * Contract single source of truth: T-C (§3.6). The server injects + * recordedBy/ownerUserId from the callback principal and enforces the three + * anchor validations — this tool only ships the observation payload. + * KEEP IN SYNC: packages/api/src/infrastructure/harness-eval/deviation/ + * report-harness-signal.ts reportHarnessSignalBodySchema. + */ + +const unitRefShape = z.object({ + unitType: z + .string() + .min(1) + .describe("Registered unit type. V1 registry: 'segment' only (skill/sop/mcp_gotcha land with later slices)."), + unitId: z.string().min(1).describe('Unit instance id, e.g. a segment id from the segment registry.'), +}); + +const attributionShape = z.object({ + objectiveId: z + .string() + .min(1) + .describe( + 'Objective this deviation counts against. Call cat_cafe_list_objectives to discover valid ids ' + + '(e.g. obj-routing-delivery, obj-identity-integrity) — do not invent one.', + ), + unitRefs: z.array(unitRefShape).min(1).describe('Units (e.g. prompt segments) this observation attributes to.'), + weight: z + .number() + .gt(0) + .max(1) + .describe( + 'Attribution weight in (0,1]. NOT part of incident identity — re-reporting with a different weight is still the same incident.', + ), +}); + +const sourceAnchorShape = z + .discriminatedUnion('kind', [ + z.object({ + kind: z.literal('thread_message'), + messageId: z.string().min(1).describe('Message the observation is anchored to (must exist, same owner).'), + }), + z.object({ + kind: z.literal('operator_confirmation'), + confirmationId: z + .string() + .min(1) + .describe('Operator confirmation id (candidate 转正通道 — not yet backed in V1).'), + }), + ]) + .describe( + 'REQUIRED evidence anchor. Anchor-less verbal corrections stay candidates and are NOT reportable via this tool.', + ); + +export const reportHarnessSignalInputSchema = { + sourceAnchor: sourceAnchorShape, + subjectCatId: z.string().min(1).describe('Cat the observation is ABOUT (independent of who reports it).'), + source: z + .enum(['operator', 'peer', 'self']) + .describe( + 'Who made the semantic judgement: operator (relaying an operator correction — anchor author must then be the operator), peer (you observed another cat), self (self-report).', + ), + note: z.string().min(1).describe('What deviated, in one or two sentences. Stored verbatim as governance evidence.'), + attributions: z.array(attributionShape).min(1).describe('objectiveId must be unique across entries.'), + idempotencyKey: z + .string() + .min(1) + .optional() + .describe('Optional network-retry token (scoped to you + thread). Reuse ONLY when retrying the same call.'), + agentKeyCatId: z + .string() + .min(1) + .optional() + .describe('Persistent-agent identity selector. Required for shared Antigravity MCP.'), +}; + +interface ReportHarnessSignalToolInput { + sourceAnchor: + | { kind: 'thread_message'; messageId: string } + | { kind: 'operator_confirmation'; confirmationId: string }; + subjectCatId: string; + source: 'operator' | 'peer' | 'self'; + note: string; + attributions: Array<{ objectiveId: string; unitRefs: Array<{ unitType: string; unitId: string }>; weight: number }>; + idempotencyKey?: string; + agentKeyCatId?: string; +} + +export async function handleReportHarnessSignalTool(input: ReportHarnessSignalToolInput): Promise { + const { agentKeyCatId, ...body } = input; + return callbackPost('/api/callbacks/harness-signals/report', body, { + ...(agentKeyCatId ? { agentKeyCatId } : {}), + // T-C await-append: the write is synchronous and idempotency-guarded server-side; + // auto-retry replaying a non-idempotent POST is the failure mode to avoid. + retryDelaysMs: [], + }); +} + +export const reportHarnessSignalTools = [ + { + name: 'cat_cafe_report_harness_signal', + description: + 'F257: report a SEMANTIC deviation observation (manual_observation) into the harness deviation ledger. ' + + 'Use when you observe behavior drifting from an objective (跑歪/绕路/身份漂移/该退不退…) and the judgement ' + + 'cannot be expressed as a static predicate — operator corrections you are relaying (source=operator), ' + + 'observations about another cat (source=peer), or self-reports (source=self). ' + + 'Your identity is attached server-side from your callback principal (recordedBy) — subjectCatId is who the observation is ABOUT. ' + + 'GOTCHA: sourceAnchor is REQUIRED and must point at an existing same-owner message; source=operator additionally requires that message to be authored by the operator. No anchor → do not report; ask the operator to confirm first. ' + + 'GOTCHA: outcome=incident_claimed means the same incident (same anchor+subject+objective/unit set) is already recorded under the returned eventId — this is dedup, not an error; do not retry with tweaked weights. ' + + 'GOTCHA: this is a governance evidence ledger (TTL=0) — report observed deviations, not speculation; put the reasoning in note. ' + + 'F257 V2 (撞到 4xx 锅拦截 → anomaly 上报引用 ledger id): when a tool call was REJECTED (4xx) and the rejection ' + + 'response carried a `ledgerId` (e.g. mcp/hold-ball-rate-limit), quote that EXACT ledgerId string in your note — ' + + 'it attributes the friction to that guard pot (idempotent stats + eval:friction guard-anomaly channel).', + inputSchema: reportHarnessSignalInputSchema, + handler: handleReportHarnessSignalTool, + }, +] as const; diff --git a/packages/mcp-server/test/callback-retry.test.js b/packages/mcp-server/test/callback-retry.test.js index 0d570d5aee..0e4034167b 100644 --- a/packages/mcp-server/test/callback-retry.test.js +++ b/packages/mcp-server/test/callback-retry.test.js @@ -87,3 +87,81 @@ describe('postJsonWithRetry — fetch timeout (hung socket)', () => { assert.equal(result.ok, true, 'a fast response must not be aborted by the timeout'); }); }); + +// 砚砚 2026-06-17 P1: per-call timeout + no-retry override for long, side-effectful +// routes (cat_cafe_publish_verdict). Without these, the publish route (~17s) is +// aborted at 10s AND retried 3× → 4 overlapping server-side publishes racing on +// the same branch. +describe('postJsonWithRetry — per-call override (publish-verdict)', () => { + let originalEnv; + let originalFetch; + + beforeEach(() => { + originalEnv = { ...process.env }; + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) delete process.env[key]; + } + Object.assign(process.env, originalEnv); + globalThis.fetch = originalFetch; + }); + + test('retryDelaysMs=[] makes exactly ONE attempt on a 5xx (no overlapping retries)', async () => { + const { postJsonWithRetry } = await import('../dist/tools/callback-retry.js'); + + let attemptCount = 0; + globalThis.fetch = async () => { + attemptCount += 1; + return { ok: false, status: 503, text: async () => 'busy', json: async () => ({}) }; + }; + + const result = await postJsonWithRetry('http://127.0.0.1:1/publish', '{}', []); + assert.equal(result.ok, false, '503 still surfaces as failure'); + assert.equal(attemptCount, 1, 'retryDelaysMs=[] must NOT retry — exactly one POST to a non-idempotent route'); + }); + + test('fetchTimeoutMs override widens the per-attempt bound beyond the env default', async () => { + // Global default forced tiny; the override must take precedence so a route + // that legitimately takes longer than the default is not falsely aborted. + process.env.CAT_CAFE_CALLBACK_FETCH_TIMEOUT_MS = '20'; + const { postJsonWithRetry } = await import('../dist/tools/callback-retry.js'); + + let observedTimeoutMs = null; + globalThis.fetch = (_url, opts) => + new Promise((resolve, reject) => { + // Capture the signal so we can prove the override (not the 20ms env) is in force. + const signal = opts?.signal; + if (signal) signal.addEventListener('abort', () => reject(signal.reason)); + // Resolve at 80ms — would be aborted under the 20ms env default, but the + // 5000ms override keeps it alive. + setTimeout(() => { + observedTimeoutMs = 5000; + resolve({ ok: true, json: async () => ({ status: 'ok' }) }); + }, 80); + }); + + const result = await postJsonWithRetry('http://127.0.0.1:1/publish', '{}', [], undefined, { + fetchTimeoutMs: 5000, + }); + assert.equal(result.ok, true, 'override (5000ms) must keep the 80ms response alive despite 20ms env default'); + assert.equal(observedTimeoutMs, 5000, 'response should have resolved under the override window'); + }); + + test('without override, the env default still aborts a slow fetch (override is opt-in)', async () => { + process.env.CAT_CAFE_CALLBACK_FETCH_TIMEOUT_MS = '20'; + const { postJsonWithRetry } = await import('../dist/tools/callback-retry.js'); + + globalThis.fetch = (_url, opts) => + new Promise((_resolve, reject) => { + const signal = opts?.signal; + if (signal) signal.addEventListener('abort', () => reject(signal.reason ?? new Error('aborted'))); + // never resolves on its own — only the 20ms timeout can settle it + }); + + const result = await postJsonWithRetry('http://127.0.0.1:1/x', '{}', []); + assert.equal(result.ok, false, 'no override → env default (20ms) still bounds the attempt'); + }); +}); diff --git a/packages/mcp-server/test/cross-post-message-targetcats.test.js b/packages/mcp-server/test/cross-post-message-targetcats.test.js index 404a0bc8d0..aa9ddd7c1f 100644 --- a/packages/mcp-server/test/cross-post-message-targetcats.test.js +++ b/packages/mcp-server/test/cross-post-message-targetcats.test.js @@ -84,10 +84,19 @@ describe('F193 AC-A4 P1 (codex review): cross_post_message fails closed at MCP l globalThis.fetch = originalFetch; }); - test('reject when no targetCats AND no line-start @ — no HTTP dispatch', async () => { - let fetchCalled = false; - globalThis.fetch = async () => { - fetchCalled = true; + test('reject when no targetCats AND no line-start @ — no message dispatch, guard rejection reported', async () => { + // F257 V2 (AC-B1): the fail-closed rejection now emits a fire-and-forget + // guard-rejection REPORT (observability, not a message dispatch). The + // original intent of this test — the MESSAGE must never be dispatched — + // is preserved by distinguishing the two endpoints. + let dispatchCalled = false; + let guardReportCalls = 0; + globalThis.fetch = async (url) => { + if (String(url).includes('/api/callbacks/guard-rejections')) { + guardReportCalls++; + return { ok: true, json: async () => ({ accepted: true }) }; + } + dispatchCalled = true; return { ok: true, json: async () => ({ status: 'ok' }) }; }; const { handleCrossPostMessage } = await import('../dist/tools/callback-tools.js'); @@ -98,11 +107,13 @@ describe('F193 AC-A4 P1 (codex review): cross_post_message fails closed at MCP l assert.equal(result.isError, true, 'must reject when no routing creds'); const text = result.content[0].text; assert.ok(text.includes('routing'), `error must mention routing, got: ${text}`); + assert.ok(text.includes('mcp/cross-post-routing-credentials'), 'rejection carries the ledger pot coordinate'); assert.equal( - fetchCalled, + dispatchCalled, false, - 'MCP fail-closed must reject EARLY — no HTTP dispatch (closes API-layer gap for agent-key callers)', + 'MCP fail-closed must reject EARLY — no message dispatch (closes API-layer gap for agent-key callers)', ); + assert.equal(guardReportCalls, 1, 'AC-B1: MCP-local rejection reports one guard-rejection event'); }); test('reject when agent-key caller cross-posts without routing creds (closes API-layer gap)', async () => { @@ -209,9 +220,14 @@ describe('F193 AC-A4 P1 (codex review): cross_post_message fails closed at MCP l }); test('reject @ in fenced code block (server parser strips code fences too)', async () => { - let fetchCalled = false; - globalThis.fetch = async () => { - fetchCalled = true; + // F257 V2 (AC-B1): distinguish message dispatch from the fire-and-forget + // guard-rejection report — the message must never be dispatched. + let dispatchCalled = false; + globalThis.fetch = async (url) => { + if (String(url).includes('/api/callbacks/guard-rejections')) { + return { ok: true, json: async () => ({ accepted: true }) }; + } + dispatchCalled = true; return { ok: true, json: async () => ({ status: 'ok' }) }; }; const { handleCrossPostMessage } = await import('../dist/tools/callback-tools.js'); @@ -220,6 +236,6 @@ describe('F193 AC-A4 P1 (codex review): cross_post_message fails closed at MCP l content: 'see code:\n```\n@codex this is in a code block\n```\nno real mention', }); assert.equal(result.isError, true, '@ inside fenced code block must NOT pass routing gate'); - assert.equal(fetchCalled, false, 'no HTTP dispatch when only routing creds are inside code fences'); + assert.equal(dispatchCalled, false, 'no message dispatch when only routing creds are inside code fences'); }); }); diff --git a/packages/mcp-server/test/hold-ball-no-retry-429.test.js b/packages/mcp-server/test/hold-ball-no-retry-429.test.js new file mode 100644 index 0000000000..cefc809056 --- /dev/null +++ b/packages/mcp-server/test/hold-ball-no-retry-429.test.js @@ -0,0 +1,197 @@ +/** + * F257 fix (verdict PR #39): hold_ball must NOT auto-retry on 429. + * + * Root cause: callback-retry treats 429 as retryable (shouldRetryStatus). + * hold_ball's 429 means "MAX_HOLDS_PER_WINDOW (3/h) reached" — the window + * is 1 hour, so retrying in 1s/2s/4s will never succeed. The default retry + * policy caused 3 identical POSTs, each emitting a GuardRejectionEvent, + * hitting the threshold-escalation trigger on retry noise rather than + * genuine independent violations. + * + * Fix: handleHoldBall passes { retryDelaysMs: [] } to callbackPost, making + * it a single-attempt call. The 429 error is still surfaced to the cat. + * + * Evidence: 3 events in 3,032ms from thread_mrkn6povq4zzgh45/gpt52, + * intervals ~1,016ms and ~2,016ms matching DEFAULT_RETRY_DELAYS_MS [1000, 2000, 4000]. + * + * Fixture env vars: CAT_CAFE_API_URL, CAT_CAFE_INVOCATION_ID, CAT_CAFE_CALLBACK_TOKEN + * (matches getCallbackConfig in callback-tools.ts:139-165). Higher-priority + * credential sources (CAT_CAFE_CREDENTIAL_FILE, agent-key variants) are + * explicitly cleared so the test is self-contained and CI-portable. + */ + +import assert from 'node:assert/strict'; +import { afterEach, beforeEach, describe, test } from 'node:test'; + +/** Keys to save/restore — covers all credential resolution paths. */ +const ENV_KEYS = [ + 'CAT_CAFE_API_URL', + 'CAT_CAFE_INVOCATION_ID', + 'CAT_CAFE_CALLBACK_TOKEN', + 'CAT_CAFE_CREDENTIAL_FILE', + 'CAT_CAFE_AGENT_KEY_SECRET', + 'CAT_CAFE_AGENT_KEY_FILE', + 'CAT_CAFE_AGENT_KEY_FILES', + 'CAT_CAFE_CALLBACK_RETRY_DELAYS_MS', + 'CAT_CAFE_CALLBACK_FETCH_TIMEOUT_MS', +]; + +/** Classify a fetch URL by endpoint. */ +function endpointOf(url) { + const s = String(url); + if (s.includes('/api/callbacks/hold-ball')) return 'hold-ball'; + if (s.includes('/api/callbacks/freshness-hold-ball-reminder')) return 'freshness'; + return 'other'; +} + +describe('hold_ball 429 — no auto-retry (F257 fix)', () => { + let originalFetch; + const savedEnv = {}; + + beforeEach(() => { + originalFetch = globalThis.fetch; + // Save and clear all credential env vars so we control the exact config + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + // Set the exact vars getCallbackConfig reads (callback-tools.ts:139-154) + process.env.CAT_CAFE_API_URL = 'http://127.0.0.1:19999'; + process.env.CAT_CAFE_INVOCATION_ID = 'test-invocation-id'; + process.env.CAT_CAFE_CALLBACK_TOKEN = 'test-callback-token'; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + // Restore all env vars exactly + for (const key of ENV_KEYS) { + if (savedEnv[key] !== undefined) { + process.env[key] = savedEnv[key]; + } else { + delete process.env[key]; + } + } + }); + + test('429 response causes exactly 1 hold-ball POST (no retry)', async () => { + const counts = { 'hold-ball': 0, freshness: 0, other: 0 }; + + globalThis.fetch = async (url) => { + counts[endpointOf(url)]++; + return { + ok: false, + status: 429, + text: async () => + JSON.stringify({ + error: 'maxHoldsPerWindow (3 per ~1h window) reached.', + holdsInWindow: 3, + maxHoldsPerWindow: 3, + windowMs: 3600000, + }), + json: async () => ({}), + }; + }; + + const { handleHoldBall } = await import('../dist/tools/callback-tools.js'); + const result = await handleHoldBall({ + reason: 'test wait', + nextStep: 'pass ball', + wakeAfterMs: 10000, + waitSourceRef: { + kind: 'github_issue', + value: '#test-1', + expectedSignal: 'close', + slaUntilMs: Date.now() + 60000, + }, + }); + + assert.equal(counts['hold-ball'], 1, 'hold_ball 429: exactly 1 POST to hold-ball endpoint'); + assert.equal(counts.freshness, 0, '429 path should not call freshness reminder'); + assert.equal(result.isError, true, '429 should surface as an error to the cat'); + }); + + test('successful hold_ball: exactly 1 hold + 1 freshness fetch', async () => { + const counts = { 'hold-ball': 0, freshness: 0, other: 0 }; + + globalThis.fetch = async (url) => { + const ep = endpointOf(url); + counts[ep]++; + if (ep === 'hold-ball') { + return { + ok: true, + status: 200, + json: async () => ({ taskId: 'hold-ball-test-123', scheduled: true }), + }; + } + // Freshness reminder (F254 B2) — return no reminder + return { + ok: true, + status: 200, + json: async () => ({}), + }; + }; + + const { handleHoldBall } = await import('../dist/tools/callback-tools.js'); + const result = await handleHoldBall({ + reason: 'test wait', + nextStep: 'check CI', + wakeAfterMs: 10000, + waitSourceRef: { + kind: 'github_issue', + value: '#test-2', + expectedSignal: 'merge', + slaUntilMs: Date.now() + 60000, + }, + }); + + assert.ok(!result.isError, 'successful hold_ball should not be an error'); + assert.equal(counts['hold-ball'], 1, 'success path: exactly 1 POST to hold-ball'); + assert.equal(counts.freshness, 1, 'success path: exactly 1 POST to freshness reminder'); + }); + + test('red-green: with env retry overrides, 429 would cause 4 POSTs without the fix', async () => { + // This test proves the fix is load-bearing: if retryDelaysMs=[] were + // removed from handleHoldBall, the default retry policy would kick in. + // We use env override to set fast retries so this test runs quickly. + // + // With the fix in place: retryDelaysMs=[] takes precedence over env → + // exactly 1 POST. This is the "green" assertion. + // + // To verify the "red" side: temporarily remove `{ retryDelaysMs: [] }` + // from callback-tools.ts → rebuild → this test should see 4 POSTs and fail. + process.env.CAT_CAFE_CALLBACK_RETRY_DELAYS_MS = '0,0,0'; + + const counts = { 'hold-ball': 0, freshness: 0, other: 0 }; + + globalThis.fetch = async (url) => { + counts[endpointOf(url)]++; + return { + ok: false, + status: 429, + text: async () => JSON.stringify({ error: 'rate limited' }), + json: async () => ({}), + }; + }; + + const { handleHoldBall } = await import('../dist/tools/callback-tools.js'); + await handleHoldBall({ + reason: 'test retry override', + nextStep: 'should not retry', + wakeAfterMs: 10000, + waitSourceRef: { + kind: 'github_issue', + value: '#test-3', + expectedSignal: 'label', + slaUntilMs: Date.now() + 60000, + }, + }); + + // With the fix: retryDelaysMs=[] overrides env → 1 POST + // Without the fix: env [0,0,0] → 4 POSTs (1 initial + 3 retries) + assert.equal( + counts['hold-ball'], + 1, + 'retryDelaysMs=[] in code must override env default — exactly 1 POST even with env retries set', + ); + }); +}); diff --git a/packages/mcp-server/test/list-objectives-tool.test.js b/packages/mcp-server/test/list-objectives-tool.test.js new file mode 100644 index 0000000000..ea875ad323 --- /dev/null +++ b/packages/mcp-server/test/list-objectives-tool.test.js @@ -0,0 +1,77 @@ +/** + * F257 #3 (2a R1 P2-1) — cat_cafe_list_objectives handler tests. + * + * Focused formatting + failure-path coverage. Mocks globalThis.fetch since the + * handler calls the API discovery route internally. Proves: success formatting + * (id — statement, no segments), honest empty, and that transport/HTTP failures + * surface as errorResult (isError) rather than a misleading empty catalog. + */ + +import assert from 'node:assert/strict'; +import { after, afterEach, before, describe, test } from 'node:test'; + +let handleListObjectives; +let originalFetch; + +before(async () => { + ({ handleListObjectives } = await import('../dist/tools/list-objectives-tool.js')); + originalFetch = globalThis.fetch; +}); + +after(() => { + globalThis.fetch = originalFetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe('F257 #3 — handleListObjectives', () => { + test('formats registered objectives (id — statement, no segments)', async () => { + globalThis.fetch = async () => ({ + ok: true, + json: async () => ({ + registryVersion: 1, + objectives: [ + { id: 'obj-routing-delivery', statement: '球权路由准确送达' }, + { id: 'obj-identity-integrity', statement: '签名/身份正确' }, + ], + }), + }); + const res = await handleListObjectives(); + assert.notEqual(res.isError, true); + const text = res.content[0].text; + assert.match(text, /obj-routing-delivery — 球权路由准确送达/); + assert.match(text, /obj-identity-integrity — 签名\/身份正确/); + assert.doesNotMatch(text, /segments/i, 'no segments authority leaks into output'); + assert.match(text, /do not invent/i, 'guides cats to pick, not invent'); + }); + + test('honest empty for a valid-but-empty catalog', async () => { + globalThis.fetch = async () => ({ ok: true, json: async () => ({ registryVersion: 1, objectives: [] }) }); + const res = await handleListObjectives(); + assert.notEqual(res.isError, true); + assert.match(res.content[0].text, /No objectives registered yet/); + }); + + test('HTTP failure (503 fail-closed) surfaces as errorResult, not empty success', async () => { + globalThis.fetch = async () => ({ + ok: false, + status: 503, + text: async () => 'Objective registry unavailable: registry unreadable', + }); + const res = await handleListObjectives(); + assert.equal(res.isError, true, 'failure must be an error result'); + assert.match(res.content[0].text, /503/); + assert.match(res.content[0].text, /unavailable/i); + }); + + test('network error surfaces as errorResult', async () => { + globalThis.fetch = async () => { + throw new Error('ECONNREFUSED'); + }; + const res = await handleListObjectives(); + assert.equal(res.isError, true); + assert.match(res.content[0].text, /ECONNREFUSED/); + }); +}); diff --git a/packages/mcp-server/test/tool-registration.test.js b/packages/mcp-server/test/tool-registration.test.js index 3dfe2896fd..d114f4c158 100644 --- a/packages/mcp-server/test/tool-registration.test.js +++ b/packages/mcp-server/test/tool-registration.test.js @@ -38,6 +38,9 @@ const EXPECTED_TOOLS = [ 'cat_cafe_list_events', 'cat_cafe_backfill_events', 'cat_cafe_get_rich_block_rules', + // F257 V1 (PR #42) + #3: harness-signal report + objective discovery + 'cat_cafe_report_harness_signal', + 'cat_cafe_list_objectives', 'cat_cafe_register_pr_tracking', 'cat_cafe_register_issue_tracking', 'cat_cafe_unregister_tracking', @@ -171,6 +174,9 @@ const EXPECTED_COLLAB_TOOLS = [ 'cat_cafe_list_events', 'cat_cafe_backfill_events', 'cat_cafe_get_rich_block_rules', + // F257 V1 (PR #42) + #3: harness-signal report + objective discovery + 'cat_cafe_report_harness_signal', + 'cat_cafe_list_objectives', 'cat_cafe_request_permission', 'cat_cafe_check_permission_status', 'cat_cafe_register_pr_tracking', @@ -330,6 +336,17 @@ describe('MCP Server Tool Registration', () => { assert.ok(checkTool, 'check_permission_status tool should exist'); }); + test('publish_verdict description enforces the artifact-store boundary', async () => { + const { createServer } = await import('../dist/index.js'); + const server = createServer(); + const tool = server._registeredTools.cat_cafe_publish_verdict; + assert.ok(tool, 'publish_verdict tool should exist'); + assert.match(tool.description, /durable runtime artifact/i); + assert.match(tool.description, /artifactId.*artifactUrl.*verdictPath.*bundleDir/s); + assert.match(tool.description, /must not be committed, pushed, or opened as a Git PR/i); + assert.doesNotMatch(tool.description, /auto-PR|commitSha|prUrl|verdict\/auto/i); + }); + // F167 Phase P fix: hold_ball description must steer "等人" to @co-creator/@cat, NOT hold_ball, // and scope wakeWhen to local commands (concept-boundary hardening — primary root cause). test('hold_ball description excludes "等人" waits and scopes wakeWhen (F167 Phase P)', async () => { @@ -523,7 +540,7 @@ const KNOWN_WRITE_TOOLS = [ 'cat_cafe_register_scheduled_task', 'cat_cafe_remove_scheduled_task', 'cat_cafe_hold_ball', // callbackPost → writes scheduled task - // F192 Phase H AC-H4: publish verdict creates branch + commit + PR (write) + // F192/F257: publish verdict creates a durable runtime artifact (write) 'cat_cafe_publish_verdict', 'cat_cafe_feat_index', // requires callback credentials unavailable in readonly // F236 Phase C: set_read_mode writes mode file via callbackPost @@ -550,6 +567,7 @@ const EXPECTED_READONLY_TOOLS = [ 'cat_cafe_list_recent', // F188 Phase F AC-F2 // cat_cafe_reflect removed in F193 Phase D AC-D1 'cat_cafe_get_rich_block_rules', + 'cat_cafe_list_objectives', // F257 #3: objective registry discovery (A_READ_LOCAL) 'cat_cafe_list_session_chain', 'cat_cafe_read_session_events', 'cat_cafe_read_session_digest', From b5f3e7b88167e9bc6ba967b42f7db96d585853ae Mon Sep 17 00:00:00 2001 From: mindfn Date: Wed, 29 Jul 2026 22:35:32 +0800 Subject: [PATCH 04/15] feat(f257): console lifeline, replay, eval window and enablement matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R6 split: D — Console/web UI for segment lifeline, replay, eval window provenance, and actionable stage. --- .../src/__tests__/actionable-stage.test.ts | 418 ++++++++++++++++++ .../__tests__/eval-window-provenance.test.ts | 395 +++++++++++++++++ .../f257-signature-lint-reachability.test.ts | 61 +++ .../segment-lifeline-ui-contract.test.ts | 347 +++++++++++++++ .../__tests__/verdict-explanations.test.ts | 114 +++++ .../app/showcase/f257-eval-window/page.tsx | 170 +++++++ .../components/settings/CreateVersionForm.tsx | 127 ++++++ .../components/settings/EvalStagePanel.tsx | 302 +++++++++++++ .../settings/GovernanceStagePanel.tsx | 218 +++++++++ .../components/settings/LifelineChainView.tsx | 284 ++++++++++++ .../settings/LifelineStageDetail.tsx | 414 +++++++++++++++++ .../settings/SegmentEditorModal.tsx | 294 ++++++++++-- .../settings/SegmentLifelineModal.tsx | 284 ++++++++++++ .../settings/SegmentReplayPanel.tsx | 380 ++++++++++++++++ .../components/settings/StageDetailPanels.tsx | 43 +- .../components/settings/VersionActions.tsx | 190 ++++++++ .../LifelineStageDetail-replay.test.tsx | 178 ++++++++ .../__tests__/SegmentEditorModal.test.tsx | 360 +++++++++++++++ .../__tests__/SegmentReplayPanel.test.tsx | 296 +++++++++++++ .../__tests__/VersionActions.test.tsx | 302 +++++++++++++ .../settings/primitives/SettingsText.tsx | 6 + .../settings/verdict-explanations.ts | 93 ++++ .../story-player/ReplayMessageList.tsx | 217 ++------- packages/web/src/hooks/useAgentMessages.ts | 25 +- packages/web/src/hooks/useChatHistory.ts | 14 +- .../web/src/lib/capability-tips.seed.json | 16 + .../lib/story-player/thread-replay-fetcher.ts | 107 ++++- packages/web/src/stores/chat-types.ts | 18 + 28 files changed, 5439 insertions(+), 234 deletions(-) create mode 100644 packages/web/src/__tests__/actionable-stage.test.ts create mode 100644 packages/web/src/__tests__/eval-window-provenance.test.ts create mode 100644 packages/web/src/__tests__/f257-signature-lint-reachability.test.ts create mode 100644 packages/web/src/__tests__/segment-lifeline-ui-contract.test.ts create mode 100644 packages/web/src/__tests__/verdict-explanations.test.ts create mode 100644 packages/web/src/app/showcase/f257-eval-window/page.tsx create mode 100644 packages/web/src/components/settings/CreateVersionForm.tsx create mode 100644 packages/web/src/components/settings/EvalStagePanel.tsx create mode 100644 packages/web/src/components/settings/GovernanceStagePanel.tsx create mode 100644 packages/web/src/components/settings/LifelineChainView.tsx create mode 100644 packages/web/src/components/settings/LifelineStageDetail.tsx create mode 100644 packages/web/src/components/settings/SegmentLifelineModal.tsx create mode 100644 packages/web/src/components/settings/SegmentReplayPanel.tsx create mode 100644 packages/web/src/components/settings/VersionActions.tsx create mode 100644 packages/web/src/components/settings/__tests__/LifelineStageDetail-replay.test.tsx create mode 100644 packages/web/src/components/settings/__tests__/SegmentEditorModal.test.tsx create mode 100644 packages/web/src/components/settings/__tests__/SegmentReplayPanel.test.tsx create mode 100644 packages/web/src/components/settings/__tests__/VersionActions.test.tsx create mode 100644 packages/web/src/components/settings/verdict-explanations.ts diff --git a/packages/web/src/__tests__/actionable-stage.test.ts b/packages/web/src/__tests__/actionable-stage.test.ts new file mode 100644 index 0000000000..9f2e8d34d1 --- /dev/null +++ b/packages/web/src/__tests__/actionable-stage.test.ts @@ -0,0 +1,418 @@ +/** + * F257 #6 slice 6b (rework per sol R1 + operator option B) — 判据① + * activeStage / actionableStage UI behavior tests (jsdom render, not source-regex). + * + * Original incident (V2 msg 0001784469056616-000054): Console painted the + * SYNTHESIZED governance.pending (from any alive/dormant verdict) as + * "待处理 / 需 operator 决策" while no Candidate existed — the exact false + * signal these tests guard against. 固化 boundary (main msg + * 0001784469935300-000115): activeStage (real loop stage, unmeasurable → + * tracing) ≠ actionableStage (real pending Candidate count only). + */ + +import { act, createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { GovernanceStagePanel } from '../components/settings/GovernanceStagePanel'; +import { LifelineChainView } from '../components/settings/LifelineChainView'; +import { LifelineStageDetail } from '../components/settings/LifelineStageDetail'; + +// ── Fixtures ────────────────────────────────────────────────── + +type Verdict = string | null; + +function makeEpoch(overrides: { + version?: number; + isActive?: boolean; + verdict?: Verdict; + governanceDecision?: string | null; + observations?: number; +}) { + const { version = 1, isActive = true, verdict = null, governanceDecision = null, observations = 0 } = overrides; + return { + version, + origin: 'manifest', + startedAt: 0, + status: 'idle', + isActive, + tracing: + observations > 0 + ? // 判据② P1 (sol R5): fixture observations are fired rows (observe-only + // semantics covered by eval-window-provenance tests). + { observationCount: observations, firedCount: observations, firstAt: 1, lastAt: 2 } + : null, + eval: verdict ? { verdict, injectionCount: 10, violationCount: 1, evaluatedAt: 1000 } : null, + governance: governanceDecision ? { decision: governanceDecision, decidedAt: null, actorId: null } : null, + events: [], + }; +} + +const UNAVAILABLE = { stage: null, candidateCount: null, source: 'unavailable' } as const; + +function makeEnablementMatrix(): import('@cat-cafe/shared').SegmentEnablementMatrix { + return { + segmentId: 'S-x', + safetyTier: 'editable', + allowLocalOverride: true, + disableable: true, + localOverlay: { + hasOverlay: false, + hasBackup: false, + actions: { + edit: { allowed: true, reason: null, reasonCode: null }, + restoreBackup: { allowed: false, reason: '当前段无备份文件', reasonCode: 'no-backup' }, + reset: { allowed: false, reason: '当前段无本地覆盖可重置', reasonCode: 'no-local-overlay' }, + }, + }, + runtimeOverride: { + enabled: true, + hasOverride: false, + hasContentOverride: false, + hasVersionSnapshot: false, + availableEpochVersions: [], + actions: { + disable: { allowed: true, reason: null, reasonCode: null }, + enable: { allowed: false, reason: '当前段已启用', reasonCode: 'already-enabled' }, + rollback: { allowed: false, reason: '当前段无覆盖可回滚', reasonCode: 'no-override' }, + activateVersion: { allowed: false, reason: '当前段无保留版本可激活', reasonCode: 'no-version-snapshot' }, + }, + }, + }; +} + +// ── Render harness ──────────────────────────────────────────── + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + document.body.removeChild(container); + delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT; +}); + +async function render(element: React.ReactElement) { + await act(async () => { + root.render(element); + }); +} + +/** Find the stage badge button whose text starts with the given label. */ +function badge(label: string): HTMLButtonElement { + const btn = [...container.querySelectorAll('button')].find((b) => b.textContent?.startsWith(label)); + expect(btn, `badge "${label}" rendered`).toBeTruthy(); + return btn as HTMLButtonElement; +} + +/** The actionable amber dot is an aria-hidden span inside the badge button. */ +function hasActionableDot(btn: HTMLButtonElement): boolean { + return btn.querySelector('span[aria-hidden="true"]') !== null; +} + +// ── 判据① chain view behavior ──────────────────────────────── + +describe('判据① LifelineChainView — activeStage loop marker', () => { + it('unmeasurable: loop marker ◈ sits on tracing, NOT governance (active 回 tracing)', async () => { + const epoch = makeEpoch({ verdict: 'unmeasurable', observations: 18 }); + await render( + createElement(LifelineChainView, { + chain: [epoch], + selected: null, + onSelect: () => {}, + activeStage: 'tracing', + actionable: UNAVAILABLE, + }), + ); + expect(badge('tracing').textContent).toContain('◈'); + expect(badge('governance').textContent).not.toContain('◈'); + }); + + it('alive: loop marker ◈ sits on governance', async () => { + const epoch = makeEpoch({ verdict: 'alive', governanceDecision: 'pending', observations: 18 }); + await render( + createElement(LifelineChainView, { + chain: [epoch], + selected: null, + onSelect: () => {}, + activeStage: 'governance', + actionable: UNAVAILABLE, + }), + ); + expect(badge('governance').textContent).toContain('◈'); + expect(badge('tracing').textContent).not.toContain('◈'); + }); + + it('loop marker only on the ACTIVE epoch (historical epochs unmarked)', async () => { + const v1 = makeEpoch({ version: 1, isActive: false, verdict: 'alive', governanceDecision: 'pending' }); + const v2 = makeEpoch({ version: 2, isActive: true, observations: 3 }); + await render( + createElement(LifelineChainView, { + chain: [v1, v2], + selected: null, + onSelect: () => {}, + activeStage: 'tracing', + actionable: UNAVAILABLE, + }), + ); + const tracingBadges = [...container.querySelectorAll('button')].filter((b) => b.textContent?.startsWith('tracing')); + expect(tracingBadges).toHaveLength(2); + expect(tracingBadges[0].textContent).not.toContain('◈'); // v1 historical + expect(tracingBadges[1].textContent).toContain('◈'); // v2 active + }); +}); + +describe('判据① LifelineChainView — actionable honesty (the incident guard)', () => { + it('synthesized governance.pending NEVER renders 待处理 or an actionable dot', async () => { + const epoch = makeEpoch({ verdict: 'alive', governanceDecision: 'pending', observations: 18 }); + await render( + createElement(LifelineChainView, { + chain: [epoch], + selected: null, + onSelect: () => {}, + activeStage: 'governance', + actionable: UNAVAILABLE, + }), + ); + const gov = badge('governance'); + expect(container.textContent).not.toContain('待处理'); + expect(hasActionableDot(gov)).toBe(false); + expect(gov.textContent).not.toContain('待审'); + }); + + it('unavailable: governance tooltip honestly says candidate data missing (provenance gap)', async () => { + const epoch = makeEpoch({ verdict: 'alive', governanceDecision: 'pending', observations: 18 }); + await render( + createElement(LifelineChainView, { + chain: [epoch], + selected: null, + onSelect: () => {}, + activeStage: 'governance', + actionable: UNAVAILABLE, + }), + ); + expect(badge('governance').title).toContain('治理候选数据暂不可用'); + expect(badge('governance').title).not.toContain('需 operator 决策'); + }); + + it('P2-2: wording is verdict-neutral (评估完成) — dormant must NOT be labeled 评估已通过', async () => { + const epoch = makeEpoch({ verdict: 'dormant', governanceDecision: 'pending', observations: 18 }); + await render( + createElement(LifelineChainView, { + chain: [epoch], + selected: null, + onSelect: () => {}, + activeStage: 'governance', + actionable: UNAVAILABLE, + }), + ); + expect(badge('governance').title).toContain('评估完成'); + expect(container.textContent).not.toContain('评估已通过'); + expect(badge('governance').title).not.toContain('评估已通过'); + }); + + it('0 real candidates → no dot, tooltip says 无需动作', async () => { + const epoch = makeEpoch({ verdict: 'alive', governanceDecision: 'pending', observations: 18 }); + await render( + createElement(LifelineChainView, { + chain: [epoch], + selected: null, + onSelect: () => {}, + activeStage: 'governance', + actionable: { stage: null, candidateCount: 0, source: 'candidate-count' }, + }), + ); + const gov = badge('governance'); + expect(hasActionableDot(gov)).toBe(false); + expect(gov.title).toContain('无治理候选(无需动作)'); + }); + + it('N=2 real candidates → amber dot + governance(2 待审) label', async () => { + const epoch = makeEpoch({ verdict: 'alive', governanceDecision: 'pending', observations: 18 }); + await render( + createElement(LifelineChainView, { + chain: [epoch], + selected: null, + onSelect: () => {}, + activeStage: 'governance', + actionable: { stage: 'governance', candidateCount: 2, source: 'candidate-count' }, + }), + ); + const gov = badge('governance'); + expect(hasActionableDot(gov)).toBe(true); + expect(gov.textContent).toContain('2 待审'); + expect(gov.title).toContain('需 operator 决策'); + }); + + it('actionable never leaks onto a NON-active epoch (v1 historical, v2 active)', async () => { + const v1 = makeEpoch({ version: 1, isActive: false, verdict: 'alive', governanceDecision: 'pending' }); + const v2 = makeEpoch({ version: 2, isActive: true, observations: 3 }); + await render( + createElement(LifelineChainView, { + chain: [v1, v2], + selected: null, + onSelect: () => {}, + activeStage: 'tracing', + actionable: { stage: 'governance', candidateCount: 2, source: 'candidate-count' }, + }), + ); + const govBadges = [...container.querySelectorAll('button')].filter((b) => b.textContent?.startsWith('governance')); + expect(govBadges).toHaveLength(2); + expect(hasActionableDot(govBadges[0] as HTMLButtonElement)).toBe(false); // v1 historical + }); +}); + +describe('判据① R2 P1-4 — the decisive cross-state (active=tracing, actionable=governance N>0)', () => { + // retire-candidate verdict: loop is back at tracing, epoch.governance is null, + // yet 2 REAL Candidates await — UI must show them independently of governance.decision. + const crossEpoch = () => makeEpoch({ verdict: 'retire-candidate', observations: 25 }); + const crossActionable = { stage: 'governance', candidateCount: 2, source: 'candidate-count' } as const; + + it('chain: ◈ on tracing AND governance amber dot + 2 待审 (not gated by governance=null)', async () => { + await render( + createElement(LifelineChainView, { + chain: [crossEpoch()], + selected: null, + onSelect: () => {}, + activeStage: 'tracing', + actionable: crossActionable, + }), + ); + expect(badge('tracing').textContent).toContain('◈'); + const gov = badge('governance'); + expect(hasActionableDot(gov)).toBe(true); + expect(gov.textContent).toContain('2 待审'); + expect(gov.title).toContain('需 operator 决策'); + expect(gov.textContent).not.toContain('◈'); // loop marker stays on tracing + }); + + it('detail panel: shows 2 个候选待审 + CTA — must NOT deny governance items', async () => { + await render( + createElement(GovernanceStagePanel, { + version: 1, + governance: null, + guardEvents: [], + overrideState: null, + hookId: 'S-x', + onRefresh: () => {}, + isActiveEpoch: true, + activeStage: 'tracing', + actionable: crossActionable, + enablementMatrix: makeEnablementMatrix(), + }), + ); + expect(container.textContent).toContain('2 个候选待审'); + expect(container.textContent).toContain('需 operator 决策'); + expect(container.textContent).not.toContain('未进入治理环节'); + expect(container.textContent).not.toContain('暂无治理事项'); + }); +}); + +// ── 判据① governance detail panel behavior ─────────────────── + +describe('判据① GovernanceStagePanel — honest pending rendering', () => { + const baseProps = { + version: 1, + guardEvents: [], + overrideState: null, + hookId: 'S-x', + onRefresh: () => {}, + isActiveEpoch: true, + activeStage: 'governance' as const, + enablementMatrix: makeEnablementMatrix(), + }; + + it('pending + unavailable → 评估完成 + provenance gap text, NO amber pending badge', async () => { + await render( + createElement(GovernanceStagePanel, { + ...baseProps, + governance: { decision: 'pending', decidedAt: null, actorId: null }, + actionable: UNAVAILABLE, + }), + ); + expect(container.textContent).toContain('评估完成'); + expect(container.textContent).toContain('治理候选数据暂不可用'); + expect(container.textContent).toContain('provenance gap'); + expect(container.textContent).not.toContain('需 operator 决策'); + // P2-2: never 评估已通过 (dormant ≠ pass); no synthesized 待处理 either + expect(container.textContent).not.toContain('评估已通过'); + expect(container.textContent).not.toContain('待处理'); + }); + + it('pending + 2 candidates → amber 2 个候选待审 + 需 operator 决策', async () => { + await render( + createElement(GovernanceStagePanel, { + ...baseProps, + governance: { decision: 'pending', decidedAt: null, actorId: null }, + actionable: { stage: 'governance', candidateCount: 2, source: 'candidate-count' }, + }), + ); + expect(container.textContent).toContain('2 个候选待审'); + expect(container.textContent).toContain('需 operator 决策'); + }); + + it('pending + 0 candidates → 当前无治理候选(无需动作)', async () => { + await render( + createElement(GovernanceStagePanel, { + ...baseProps, + governance: { decision: 'pending', decidedAt: null, actorId: null }, + actionable: { stage: null, candidateCount: 0, source: 'candidate-count' }, + }), + ); + expect(container.textContent).toContain('当前无治理候选(无需动作)'); + expect(container.textContent).not.toContain('需 operator 决策'); + }); + + it('no governance yet → 未进入治理环节 (NOT the misleading 等待治理决策)', async () => { + await render( + createElement(GovernanceStagePanel, { + ...baseProps, + governance: null, + activeStage: 'tracing', + actionable: UNAVAILABLE, + }), + ); + expect(container.textContent).toContain('未进入治理环节'); + expect(container.textContent).toContain('当前循环位于 tracing'); + expect(container.textContent).not.toContain('等待治理决策'); + }); + + it('§16e sweep: epoch status governance-pending renders informational slate, NOT amber 待治理', async () => { + const epoch = { ...makeEpoch({ verdict: 'dormant', governanceDecision: 'pending' }), status: 'governance-pending' }; + await render( + createElement(LifelineStageDetail, { + selected: { version: 1, stage: 'version' }, + chain: [epoch], + observations: [], + guardEvents: [], + epochGuardMetrics: {}, + overrideState: null, + hookId: 'S-x', + onRefresh: () => {}, + activeStage: 'governance', + actionable: UNAVAILABLE, + enablementMatrix: makeEnablementMatrix(), + }), + ); + expect(container.textContent).toContain('评估完成·治理环节'); + expect(container.textContent).not.toContain('待治理'); + expect(container.textContent).not.toContain('评估已通过'); + }); + + it('approved still renders approved (unchanged contract)', async () => { + await render( + createElement(GovernanceStagePanel, { + ...baseProps, + governance: { decision: 'approved', decidedAt: 1720000000000, actorId: 'lang' }, + actionable: UNAVAILABLE, + }), + ); + expect(container.textContent).toContain('approved'); + }); +}); diff --git a/packages/web/src/__tests__/eval-window-provenance.test.ts b/packages/web/src/__tests__/eval-window-provenance.test.ts new file mode 100644 index 0000000000..4d81338ff6 --- /dev/null +++ b/packages/web/src/__tests__/eval-window-provenance.test.ts @@ -0,0 +1,395 @@ +/** + * F257 #6 slice 6c — 判据② eval window / denominator provenance UI tests + * (jsdom real render, not source-regex). + * + * Original incident (V2 thread, operator screenshot): lifeline showed + * tracing(18) vs eval injectionCount=0 as if contradictory — but 18 came + * from the CURRENT 7d query window while 0 came from the judgment's OWN + * historical eval window. The two coordinates were never labeled. + * + * Contract: + * - eval panel shows the judgment's OWN eval window [startMs,endMs) + + * denominatorKind, never the lifeline query window; + * - tracing panel labels the CURRENT query window as such; + * - legacy cached judgment without window/denominator → fail-visible + * "评估窗口未知 / 分母未知", never guessed from evaluatedAt. + */ + +import { act, createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { EvalStagePanel } from '../components/settings/EvalStagePanel'; +import { LifelineChainView } from '../components/settings/LifelineChainView'; +import { LifelineStageDetail } from '../components/settings/LifelineStageDetail'; + +// ── Fixtures ────────────────────────────────────────────────── + +/** The judgment's OWN historical eval window (e.g. a 1d window 10 days ago). */ +const EVAL_WINDOW = { startMs: 1_649_999_999_000, endMs: 1_650_086_399_000 }; +/** The CURRENT lifeline query window (≈ last 7d — a different coordinate). */ +const QUERY_WINDOW = { startMs: 1_750_000_000_000, endMs: 1_750_604_800_000 }; + +function makeEval(overrides: Record = {}) { + // Producer-reachable state ONLY (sol R2 P1-2): segment-judgment-engine + // produceVerdict — injectionCount=0 → 'unmeasurable' + denominatorKind 'none'. + // ('alive' + 0 + 'fired-count' is impossible under the authoritative producer.) + return { + verdict: 'unmeasurable', + injectionCount: 0, + violationCount: 0, + evaluatedAt: EVAL_WINDOW.endMs, + evalWindow: EVAL_WINDOW, + denominatorKind: 'none', + ...overrides, + }; +} + +function makeEnablementMatrix(): import('@cat-cafe/shared').SegmentEnablementMatrix { + return { + segmentId: 'S-x', + safetyTier: 'editable', + allowLocalOverride: true, + disableable: true, + localOverlay: { + hasOverlay: false, + hasBackup: false, + actions: { + edit: { allowed: true, reason: null, reasonCode: null }, + restoreBackup: { allowed: false, reason: '当前段无备份文件', reasonCode: 'no-backup' }, + reset: { allowed: false, reason: '当前段无本地覆盖可重置', reasonCode: 'no-local-overlay' }, + }, + }, + runtimeOverride: { + enabled: true, + hasOverride: false, + hasContentOverride: false, + hasVersionSnapshot: false, + availableEpochVersions: [], + actions: { + disable: { allowed: true, reason: null, reasonCode: null }, + enable: { allowed: false, reason: '当前段已启用', reasonCode: 'already-enabled' }, + rollback: { allowed: false, reason: '当前段无覆盖可回滚', reasonCode: 'no-override' }, + activateVersion: { allowed: false, reason: '当前段无保留版本可激活', reasonCode: 'no-version-snapshot' }, + }, + }, + }; +} + +function makeEpoch(overrides: Record = {}) { + return { + version: 1, + origin: 'manifest', + startedAt: 0, + // unmeasurable → cycle returns to tracing (6b loop model): eval-pending, no governance. + status: 'eval-pending', + isActive: true, + tracing: { + observationCount: 18, + firedCount: 18, + firstAt: QUERY_WINDOW.startMs + 1000, + lastAt: QUERY_WINDOW.endMs - 1000, + }, + eval: makeEval(), + governance: null, + events: [], + ...overrides, + }; +} + +// ── Render harness ──────────────────────────────────────────── + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + document.body.removeChild(container); + delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT; +}); + +async function render(element: React.ReactElement) { + await act(async () => { + root.render(element); + }); +} + +const fmt = (ms: number) => new Date(ms).toLocaleString(); + +// ── 判据② EvalStagePanel — eval window provenance ───────────── + +describe('判据② EvalStagePanel — the judgment OWN eval window', () => { + it('shows the eval window range labeled as 评估窗口 (sampling interval), not the query window', async () => { + await render(createElement(EvalStagePanel, { version: 1, eval: makeEval(), tracing: null, guardMetrics: [] })); + expect(container.textContent).toContain('评估窗口'); + expect(container.textContent).toContain(fmt(EVAL_WINDOW.startMs)); + expect(container.textContent).toContain(fmt(EVAL_WINDOW.endMs)); + // The query window must NOT be presented as the eval window. + expect(container.textContent).not.toContain(fmt(QUERY_WINDOW.startMs)); + }); + + it('shows the denominator kind of the counts (none — unmeasurable has no denominator)', async () => { + await render(createElement(EvalStagePanel, { version: 1, eval: makeEval(), tracing: null, guardMetrics: [] })); + expect(container.textContent).toContain('无分母(不可计算比率)'); + }); + + it('legacy eval (null window/denominator) → fail-visible 未知, never guessed from evaluatedAt', async () => { + await render( + createElement(EvalStagePanel, { + version: 1, + eval: makeEval({ evalWindow: null, denominatorKind: null }), + tracing: null, + guardMetrics: [], + }), + ); + expect(container.textContent).toContain('评估窗口未知'); + expect(container.textContent).toContain('分母未知'); + // Must NOT silently present evaluatedAt as the window start/end pair. + expect(container.textContent).not.toContain(`${fmt(EVAL_WINDOW.startMs)}`); + }); + + it('undefined fields (older API response) degrade to the same fail-visible unknown', async () => { + const legacy = makeEval(); + delete (legacy as Record).evalWindow; + delete (legacy as Record).denominatorKind; + await render(createElement(EvalStagePanel, { version: 1, eval: legacy, tracing: null, guardMetrics: [] })); + expect(container.textContent).toContain('评估窗口未知'); + expect(container.textContent).toContain('分母未知'); + }); +}); + +// ── 判据② 18-vs-0: two coordinates visibly distinct ────────── + +describe('判据② tracing vs eval — the 18-vs-0 incident guard', () => { + function renderStageDetail(stage: 'tracing' | 'eval') { + return render( + createElement(LifelineStageDetail, { + selected: { version: 1, stage }, + chain: [makeEpoch()], + observations: [], + guardEvents: [], + epochGuardMetrics: { 1: [] }, + overrideState: null, + hookId: 'S-x', + onRefresh: () => {}, + activeStage: 'tracing', + actionable: { stage: null, candidateCount: null, source: 'unavailable' }, + queryWindow: QUERY_WINDOW, + enablementMatrix: makeEnablementMatrix(), + }), + ); + } + + it('tracing panel labels its counts with the CURRENT query window', async () => { + await renderStageDetail('tracing'); + expect(container.textContent).toContain('18'); + expect(container.textContent).toContain('查询窗口'); + expect(container.textContent).toContain(fmt(QUERY_WINDOW.startMs)); + }); + + it('eval panel labels its counts with the judgment OWN eval window; query window appears only as labeled contrast (P1-1)', async () => { + await renderStageDetail('eval'); + expect(container.textContent).toContain('评估窗口'); + expect(container.textContent).toContain(fmt(EVAL_WINDOW.startMs)); + // P1-1: the query window MAY appear in the eval viewport — but only inside the + // coordinate-contrast block, explicitly labeled 当前查询窗口 (never as 评估窗口). + expect(container.textContent).toContain('当前查询窗口'); + expect(container.textContent).toContain(fmt(QUERY_WINDOW.startMs)); + // The eval-window row itself must carry the eval coordinate: 评估窗口 label + // precedes the eval range, and the query range never follows the 评估窗口 label. + const text = container.textContent ?? ''; + const evalLabelIdx = text.indexOf('评估窗口'); + expect(evalLabelIdx).toBeGreaterThanOrEqual(0); + expect(text.indexOf(fmt(EVAL_WINDOW.startMs), evalLabelIdx)).toBeGreaterThan(evalLabelIdx); + const queryIdxAfterEvalLabel = text.indexOf(fmt(QUERY_WINDOW.startMs), evalLabelIdx); + const evalEndIdx = text.indexOf(fmt(EVAL_WINDOW.endMs), evalLabelIdx); + expect(queryIdxAfterEvalLabel === -1 || queryIdxAfterEvalLabel > evalEndIdx).toBe(true); + }); +}); + +// ── 判据② P1-1 (sol R1): composed viewport — 18 vs 0 on two coordinates at once ── + +describe('判据② P1-1 composed render — chain + eval detail in ONE viewport', () => { + it('shows tracing(18)+query window AND eval 0+eval window+denominator in the same DOM', async () => { + await render( + createElement( + 'div', + null, + createElement(LifelineChainView, { + chain: [makeEpoch()], + selected: { version: 1, stage: 'eval' }, + onSelect: () => {}, + activeStage: 'tracing', + actionable: { stage: null, candidateCount: null, source: 'unavailable' }, + }), + createElement(LifelineStageDetail, { + selected: { version: 1, stage: 'eval' }, + chain: [makeEpoch()], + observations: [], + guardEvents: [], + epochGuardMetrics: { 1: [] }, + overrideState: null, + hookId: 'S-x', + onRefresh: () => {}, + activeStage: 'tracing', + actionable: { stage: null, candidateCount: null, source: 'unavailable' }, + queryWindow: QUERY_WINDOW, + enablementMatrix: makeEnablementMatrix(), + }), + ), + ); + const text = container.textContent ?? ''; + // Chain: current tracing count visible + expect(text).toContain('tracing(18)'); + // Eval detail: historical eval count + its OWN coordinates + expect(text).toContain('评估窗口'); + expect(text).toContain(fmt(EVAL_WINDOW.startMs)); + expect(text).toContain(fmt(EVAL_WINDOW.endMs)); + expect(text).toContain('无分母(不可计算比率)'); + // Producer-contract guard (sol R2 P1-2): unmeasurable = injectionCount 0, + // and the DENOMINATOR row must not show a fired-count label for it. (The + // contrast block legitimately names the current-side metric fired-count — + // the guard targets the eval denominator label specifically.) + expect(text).toContain('无分母'); + expect(text).not.toContain('fired-count(注入次数计数)'); + // Same viewport: the 18's coordinate (CURRENT query window) must ALSO be visible, + // explicitly labeled as a different coordinate from the eval window. + expect(text).toContain('当前查询窗口'); + expect(text).toContain(fmt(QUERY_WINDOW.startMs)); + expect(text).toContain(fmt(QUERY_WINDOW.endMs)); + }); +}); + +// ── P1 (sol R5/R6): current-side metric honesty in the contrast block ── + +describe('P1 (sol R5/R6) contrast block — fired vs observed + exact-count completeness', () => { + function renderEvalWithTracing(tracing: { + observationCount: number; + firedCount: number; + firstAt: number | null; + lastAt: number | null; + }) { + return render( + createElement(EvalStagePanel, { + version: 1, + eval: makeEval(), + tracing, + guardMetrics: [], + queryWindow: QUERY_WINDOW, + }), + ); + } + + it('observe-only rows render as 观测行数, never inflate 当前注入 (fired-count)', async () => { + await renderEvalWithTracing({ + observationCount: 1, + firedCount: 0, + firstAt: QUERY_WINDOW.startMs + 1000, + lastAt: QUERY_WINDOW.endMs - 1000, + }); + const text = container.textContent ?? ''; + expect(text).toContain('当前注入'); + expect(text).toContain('观测行数'); + expect(text).toContain('observe-only'); + // The fired metric is 0 — the single observe-only row must NOT appear as an injection. + // (Scope to the 当前注入 row only; the 观测行数 row legitimately shows 1.) + const firedRow = (text.split('当前注入')[1] ?? '').split('观测行数')[0] ?? ''; + expect(firedRow).toContain('0'); + expect(firedRow).not.toContain('1'); + }); + + it('aggregate counts are EXACT — no lower-bound markers (sol R6: completeness lives on the detail list)', async () => { + await renderEvalWithTracing({ + observationCount: 101, + firedCount: 101, + firstAt: QUERY_WINDOW.startMs + 1000, + lastAt: QUERY_WINDOW.endMs - 1000, + }); + const text = container.textContent ?? ''; + expect(text).toContain('101'); + expect(text).not.toContain('≥'); + expect(text).not.toContain('下限'); + }); + + it('detail-capped response shows the truncation note while counts stay exact (sol R6 P1)', async () => { + await render( + createElement(LifelineStageDetail, { + selected: { version: 1, stage: 'tracing' }, + chain: [ + makeEpoch({ + tracing: { + observationCount: 101, + firedCount: 101, + firstAt: QUERY_WINDOW.startMs + 1000, + lastAt: QUERY_WINDOW.endMs - 1000, + }, + }), + ], + observations: [], + observationsCapped: true, + guardEvents: [], + epochGuardMetrics: { 1: [] }, + overrideState: null, + hookId: 'S-x', + onRefresh: () => {}, + activeStage: 'tracing', + actionable: { stage: null, candidateCount: null, source: 'unavailable' }, + queryWindow: QUERY_WINDOW, + enablementMatrix: makeEnablementMatrix(), + }), + ); + const text = container.textContent ?? ''; + expect(text).toContain('101 次观测'); + expect(text).toContain('明细仅显示最近 100 条'); + expect(text).toContain('精确聚合'); + }); +}); + +// ── P2 (sol R5): gap kind — corrupted provenance must not be mislabeled legacy ── + +describe('P2 (sol R5) gap kind — invalid-present vs legacy-missing', () => { + it('invalid-present window/denominator renders 数据损坏, not 历史缓存缺字段', async () => { + await render( + createElement(EvalStagePanel, { + version: 1, + eval: makeEval({ + evalWindow: null, + evalWindowGap: 'invalid-present', + denominatorKind: null, + denominatorGap: 'invalid-present', + }), + tracing: null, + guardMetrics: [], + }), + ); + const text = container.textContent ?? ''; + expect(text).toContain('评估窗口不可用(缓存数据损坏)'); + expect(text).toContain('分母不可用(缓存数据损坏)'); + expect(text).not.toContain('历史缓存缺字段'); + }); + + it('legacy-missing gap renders the legacy wording (unchanged contract)', async () => { + await render( + createElement(EvalStagePanel, { + version: 1, + eval: makeEval({ + evalWindow: null, + evalWindowGap: 'legacy-missing', + denominatorKind: null, + denominatorGap: 'legacy-missing', + }), + tracing: null, + guardMetrics: [], + }), + ); + const text = container.textContent ?? ''; + expect(text).toContain('评估窗口未知(历史缓存缺字段)'); + expect(text).toContain('分母未知(历史缓存缺字段)'); + }); +}); diff --git a/packages/web/src/__tests__/f257-signature-lint-reachability.test.ts b/packages/web/src/__tests__/f257-signature-lint-reachability.test.ts new file mode 100644 index 0000000000..da633abf40 --- /dev/null +++ b/packages/web/src/__tests__/f257-signature-lint-reachability.test.ts @@ -0,0 +1,61 @@ +/** + * F257 #4 (sol R4 P2) — web read-model reachability for the signature-lint verdict. + * + * The server now persists/broadcasts `extra.signatureLint`, but the web ingestion + * chain rebuilds `extra` via several divergent allowlists. This proves the two + * testable pure seams preserve the field: + * - `pickSignatureLint` — the shared forwarder spread into the live-callback + * side-patches (useAgentMessages W8-W14) and cold-hydration emit (W6). + * - `mergeMessageExtra` — the cold-hydration history-merge reconcile (W7, an + * UNCITED drop point the §16e sweep surfaced), including the guard invariant + * sol flagged: a signatureLint-ONLY extra must NOT collapse to undefined. + */ + +import { describe, expect, it } from 'vitest'; +import { mergeMessageExtra } from '@/hooks/useChatHistory'; +import { pickSignatureLint } from '@/stores/chat-types'; + +describe('pickSignatureLint — shared live-callback / cold-hydration forwarder', () => { + it('forwards signed verdict', () => { + expect(pickSignatureLint({ signatureLint: { signed: true } })).toEqual({ signatureLint: { signed: true } }); + }); + + it('forwards unsigned verdict', () => { + expect(pickSignatureLint({ signatureLint: { signed: false } })).toEqual({ signatureLint: { signed: false } }); + }); + + it('returns empty when field absent (no phantom key)', () => { + expect(pickSignatureLint({})).toEqual({}); + expect(pickSignatureLint({ signatureLint: undefined })).toEqual({}); + expect(pickSignatureLint(undefined)).toEqual({}); + expect(pickSignatureLint(null)).toEqual({}); + }); +}); + +describe('mergeMessageExtra — cold-hydration reconcile preserves signatureLint (W7)', () => { + it('signatureLint-ONLY extra does NOT collapse to undefined (guard invariant)', () => { + const merged = mergeMessageExtra({ signatureLint: { signed: false } }, undefined); + expect(merged).toEqual({ signatureLint: { signed: false } }); + }); + + it('preserves signatureLint from the preferred side', () => { + const merged = mergeMessageExtra({ signatureLint: { signed: true } }, { isExplicitPost: true }); + expect(merged?.signatureLint).toEqual({ signed: true }); + expect(merged?.isExplicitPost).toBe(true); + }); + + it('falls back to signatureLint from the fallback side', () => { + const merged = mergeMessageExtra(undefined, { signatureLint: { signed: false } }); + expect(merged?.signatureLint).toEqual({ signed: false }); + }); + + it('preferred verdict wins over fallback', () => { + const merged = mergeMessageExtra({ signatureLint: { signed: true } }, { signatureLint: { signed: false } }); + expect(merged?.signatureLint).toEqual({ signed: true }); + }); + + it('coexists with other extra fields without clobbering', () => { + const merged = mergeMessageExtra({ isExplicitPost: true, signatureLint: { signed: false } }, undefined); + expect(merged).toEqual({ isExplicitPost: true, signatureLint: { signed: false } }); + }); +}); diff --git a/packages/web/src/__tests__/segment-lifeline-ui-contract.test.ts b/packages/web/src/__tests__/segment-lifeline-ui-contract.test.ts new file mode 100644 index 0000000000..4bea7a5dbe --- /dev/null +++ b/packages/web/src/__tests__/segment-lifeline-ui-contract.test.ts @@ -0,0 +1,347 @@ +/** + * F257 Phase D — Segment lifeline UI contract tests. + * + * Verifies two non-degradable UI contracts (terra P2-3/P2-4): + * 1. Guard events section surfaces "窗口关联" / "非因果" attribution + * 2. Lifeline entry point is a + ); + } + + const handleSubmit = async () => { + if (!content.trim() || !reason.trim()) return; + setBusy(true); + setError(null); + try { + const res = await apiFetch(`/api/prompt-hooks/${encodeURIComponent(hookId)}/versions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + content: content.trim(), + reason: reason.trim(), + }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + setError((body as { error?: string }).error ?? `创建失败 (${res.status})`); + return; + } + setOpen(false); + setContent(''); + setReason(''); + onRefresh(); + } catch { + setError('网络错误'); + } finally { + setBusy(false); + } + }; + + const handleCancel = () => { + setOpen(false); + setContent(''); + setReason(''); + setError(null); + }; + + return ( +
+ + 创建新版本 + +
+
+ +