Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/features/F257-harness-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ governance_actions: 合并 | 禁用 | 修改 | 新增 # 治理单位是段(
1. **昵称唯一性与模糊 @ fail-closed**(归属 F167/路由域,坐标记录于此):`cat-template.json` 把 nickname 定义在 roleTemplate(家族)层——opus 实例 nickname="宪宪"(L188) + patterns 含 @宪宪(L194),codex nickname="砚砚"(L357/363) 与 sol/terra 三猫共用 → @昵称确定性投错猫 + persona 注入错身份(dev-628ea4d1 根因坐标)。修复:nickname per-cat 唯一 + config 加载时 mentionPatterns 冲突 fail-closed + 多命中拒绝路由要求显式 handle。**状态:✅ PR #44(`10dacad2b`)已合入。**
2. **L 系列段观测粒度**:native-L0 路径 collectTrace 只记 session-init-pack-only 聚合(trace-collector.ts else-if 分支),不做 per-segment 拆解 → L 系列 Console 全体"无数据"。修复:L0 编译器持有确定段清单,直录 trace(结构化直录,不解析文本)。
3. **objective 目录运行时发现机制**:report_harness_signal 的 objectiveId 为自由 string,无 list 工具/schema 枚举/harness 注入清单——三次上报三次考古文档(含一次归因困难降权 0.6)。修复:objective registry 只读发现接口 + 工具 description 同步。
4. **签名 lint O2→O1**:消息末行签名 [昵称/模型🐾] 是完美可 lint 断言,当前零结构覆盖(dev-7a882ba0:靠 operator 人工发现)。
4. **签名 lint O2→O1**:消息末行签名 [昵称/模型🐾] 是完美可 lint 断言,当前零结构覆盖(dev-7a882ba0:靠 operator 人工发现)。**两阶段**(2026-07-20 owner vision-guardian 校准,AC 完成 ≠ feature 完成):**① 检测层**(切片 1,PR 待提)——复用 `isCatSignatureLine` 结构化检测 + post-seam 记 `message.extra.signatureLint`(message 级可观测、denominator-bearing、observe-only 非阻断);**② 账本闭环**(deferred 到切片 2 / #3 后)——检测到 miss 自动 emit deviation 归因 `obj-identity-integrity` 进 harness ledger,把 dev-7a882ba0 的**手动** `report_harness_signal` 上报**自动化**。闭环 deferred 原因:harness ledger 读 DeviationEventLog/GuardRejectionEventLog/eval verdict,**不扫 message.extra**;正确 deviation 需 registered objective(否则重蹈 #3 修的 free-string 考古)+ segment/condition 归因基建(属 #2/#3 数据根)。**`extra`-only ≠ #4 完成**。
5. **运行实例写保护**:pre-commit hook 白名单(仅 §14 共享状态文档路径),把 LI-004 从认知纪律降为结构强制(dev-af6d4e28:平行实例任务错位 merge 污染运行基线,V1 一度整体不在运行树)。**状态:✅ PR #44(`10dacad2b`)已合入。**
6. **Console 收尾包**(已固化验收判据于 V2 thread):activeStage/actionableStage 分离、eval 窗口标注(18 vs 0 类矛盾)、判定词解释、tracing 锚点回放剧场式下钻(历史版本渲染防伪造现场)、变量段编辑呈现、启禁用矩阵测试。

Expand Down
Original file line number Diff line number Diff line change
@@ -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 } : {};
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import { resolveDefaultClaudeMcpServerPath } from '../providers/ClaudeAgentServi
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';
Expand Down Expand Up @@ -1272,6 +1273,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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ import {
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';
Expand Down Expand Up @@ -2840,6 +2841,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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,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[];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -363,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;
Expand Down Expand Up @@ -393,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;

Expand Down Expand Up @@ -464,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<typeof result.a2aRouting> = {};
if (typeof parsed.a2aRouting.fromCatId === 'string') routing.fromCatId = parsed.a2aRouting.fromCatId;
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/domains/cats/services/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
28 changes: 26 additions & 2 deletions packages/api/src/routes/callbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1024,7 +1025,14 @@ export const callbacksRoutes: FastifyPluginAsync<CallbackRoutesOptions> = 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);
Expand Down Expand Up @@ -1092,6 +1100,8 @@ export const callbacksRoutes: FastifyPluginAsync<CallbackRoutesOptions> = 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 } : {}),
Expand Down Expand Up @@ -1193,6 +1203,8 @@ export const callbacksRoutes: FastifyPluginAsync<CallbackRoutesOptions> = 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 } : {}),
Expand Down Expand Up @@ -1687,7 +1699,15 @@ export const callbacksRoutes: FastifyPluginAsync<CallbackRoutesOptions> = 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
Expand Down Expand Up @@ -1817,6 +1837,8 @@ export const callbacksRoutes: FastifyPluginAsync<CallbackRoutesOptions> = 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 } : {}),
Expand Down Expand Up @@ -1925,6 +1947,8 @@ export const callbacksRoutes: FastifyPluginAsync<CallbackRoutesOptions> = 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 } : {}),
Expand Down
5 changes: 4 additions & 1 deletion packages/api/src/routes/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1870,7 +1870,8 @@ export const messagesRoutes: FastifyPluginAsync<MessagesRoutesOptions> = 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 } : {}),
Expand All @@ -1881,6 +1882,8 @@ export const messagesRoutes: FastifyPluginAsync<MessagesRoutesOptions> = 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 } : {}),
},
}
: {}),
Expand Down
Loading