fix(rectification): fail-close snapshot fingerprints without rewinding discrimination
Missing evidence fingerprints are stale, not current. Stale snapshots keep asking discriminators when a probe exists, and offer-candidates refuses until the ledger matches. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6365,6 +6365,22 @@
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-420 | 候选快照缺指纹被当成 current,stale 时把区分打回采集
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-08-28
|
||||
- 最近更新:2026-08-28
|
||||
- 影响面:`decideFromDossier`、`scoreableSnapshotIsCurrent`、`decideRectification`、GET/工具 `candidate_snapshot`
|
||||
- 用户现象:已进入区分的 Case 在快照过期或缺证据指纹时,下一问变回采集,点选卡消失。工具侧和 dossier 侧对同一份账本可能一个判 stale、一个判 current。
|
||||
- 触发条件:已有区分探针和训练门;`latest_result` 缺少 `evidence_ledger_fingerprint`,或账本指纹已变但决策器仍走 `snapshotCurrent === false → collect`。
|
||||
- 根因:dossier 用 `!storedFingerprint || stored === current` fail-open;工具用 `!storedSnapshot || !fingerprint || scoreableSnapshotIsCurrent` 同样 fail-open。`decideRectification` 把 `snapshotCurrent === false` 与方法覆盖缺失绑在一起打回 `event_collection`。
|
||||
- 修复:两处都走 `candidateSnapshotSource` + `storedSnapshotIsCurrent`。指纹缺失判 `fingerprint_missing`,不得当 current。没有已存快照仍不算 stale。快照 stale 且仍有区分探针时保持 `ask_candidate_discriminator`,receipt 写 `stale_reason`,要求重算;不把区分打回采集。不改 Skill。
|
||||
- 验证:`rectification-decide-next-action` 锁定缺指纹为 stale;训练已开+探针时 `snapshotCurrent: false` 仍是 `ask_candidate_discriminator`;训练未开仍采集。`rectification-decision-authority` 无指纹的事业/感情训练账本仍区分。`rectification-eight-method` 锁定缺指纹拒绝 `offer-candidates`;暂停逃生口和覆盖后的 34/33/33 平局只在指纹匹配时出牌。
|
||||
- 防复发:不得把空指纹写成 current。不得在已有区分探针时用 stale 快照把 `nextAction` 改成 `ask_fact_collection`。不得为了暂停出牌或平局出牌把空指纹 fail-open。`occupation_note` 仍不得单独让 scoreable 快照失效。
|
||||
- 相关记录:BUG-410、#40
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-410 | 训练已齐仍因家人/职业方法层停在采集,Agent 只确认后截断
|
||||
|
||||
- 状态:resolved
|
||||
|
||||
@@ -113,14 +113,19 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
|
||||
const canDiscriminateDespiteCoverage = Boolean(probe)
|
||||
&& !separation.sufficient
|
||||
&& input.trainingGateOpen !== false;
|
||||
if (
|
||||
((!input.methodCoverageAll && !canDiscriminateDespiteCoverage)
|
||||
|| input.trainingGateOpen === false
|
||||
|| input.snapshotCurrent === false)
|
||||
&& !(userStopped && input.candidateScores.length > 0)
|
||||
) {
|
||||
const coverageBlocks = (!input.methodCoverageAll && !canDiscriminateDespiteCoverage)
|
||||
|| input.trainingGateOpen === false;
|
||||
if (coverageBlocks && !(userStopped && input.candidateScores.length > 0)) {
|
||||
return collect(separation, holdout, range, probe);
|
||||
}
|
||||
if (input.snapshotCurrent === false) {
|
||||
if (probe && !userStopped && input.trainingGateOpen !== false) {
|
||||
return discriminate(separation, holdout, range, probe);
|
||||
}
|
||||
if (!(userStopped && input.candidateScores.length > 0)) {
|
||||
return collect(separation, holdout, range, probe);
|
||||
}
|
||||
}
|
||||
if (!separation.sufficient) {
|
||||
if (probe && !userStopped) {
|
||||
return discriminate(separation, holdout, range, probe);
|
||||
|
||||
@@ -15,13 +15,15 @@ export type SnapshotStaleReason =
|
||||
| "birth_profile_changed"
|
||||
| "scoreable_evidence_changed"
|
||||
| "candidate_set_superseded"
|
||||
| "inference_revision_changed";
|
||||
| "inference_revision_changed"
|
||||
| "fingerprint_missing";
|
||||
|
||||
export const SNAPSHOT_STALE_COPY: Readonly<Record<SnapshotStaleReason, string>> = {
|
||||
birth_profile_changed: "出生资料已变化,候选已失效",
|
||||
scoreable_evidence_changed: "可评分证据已变化,请重新比较候选",
|
||||
candidate_set_superseded: "已有更新的候选结果",
|
||||
inference_revision_changed: "候选后验已更新,请使用当前结果",
|
||||
fingerprint_missing: "候选快照缺少证据指纹,请重新比较候选",
|
||||
};
|
||||
|
||||
export function classifySnapshotStaleReason(
|
||||
@@ -29,6 +31,9 @@ export function classifySnapshotStaleReason(
|
||||
current: CandidateSnapshotSource,
|
||||
): SnapshotStaleReason | null {
|
||||
if (!snapshot) return "candidate_set_superseded";
|
||||
if (!snapshot.scoreableEvidenceFingerprint || !current.scoreableEvidenceFingerprint) {
|
||||
return "fingerprint_missing";
|
||||
}
|
||||
if (
|
||||
snapshot.birthProfileFingerprint
|
||||
&& current.birthProfileFingerprint
|
||||
@@ -54,11 +59,21 @@ export function scoreableSnapshotIsCurrent(
|
||||
current: CandidateSnapshotSource,
|
||||
): boolean {
|
||||
if (!snapshot) return false;
|
||||
if (!snapshot.scoreableEvidenceFingerprint || !current.scoreableEvidenceFingerprint) return false;
|
||||
return snapshot.scoreableEvidenceFingerprint === current.scoreableEvidenceFingerprint
|
||||
&& snapshot.candidateSetVersion === current.candidateSetVersion
|
||||
&& snapshot.inferenceRevision === current.inferenceRevision;
|
||||
}
|
||||
|
||||
/** No stored snapshot is not stale; a stored snapshot with a missing fingerprint is. */
|
||||
export function storedSnapshotIsCurrent(
|
||||
snapshot: CandidateSnapshotSource | null | undefined,
|
||||
current: CandidateSnapshotSource,
|
||||
): boolean {
|
||||
if (!snapshot) return true;
|
||||
return scoreableSnapshotIsCurrent(snapshot, current);
|
||||
}
|
||||
|
||||
export function snapshotIsCurrent(
|
||||
snapshot: CandidateSnapshotSource | null | undefined,
|
||||
current: CandidateSnapshotSource,
|
||||
|
||||
@@ -37,6 +37,11 @@ import {
|
||||
} from "./evidence-model";
|
||||
import { refinementFromDecisionReceipt } from "./refinement-packet";
|
||||
import { windowScanFromDecisionReceipt } from "./varga-observations";
|
||||
import { evidenceLedgerFingerprint } from "./tool-service";
|
||||
import {
|
||||
candidateSnapshotSource,
|
||||
storedSnapshotIsCurrent,
|
||||
} from "../core/snapshot-source.ts";
|
||||
|
||||
export type DecisionDossier = Readonly<{
|
||||
evidence: readonly Readonly<{
|
||||
@@ -75,6 +80,8 @@ export type DecisionDossier = Readonly<{
|
||||
representativeTime?: string | null;
|
||||
evidenceLedgerFingerprint?: string | null;
|
||||
candidateRangeFingerprint?: string | null;
|
||||
policyVersion?: string | null;
|
||||
algorithmVersion?: string | null;
|
||||
} | null;
|
||||
case: {
|
||||
acceptedTime: string | null;
|
||||
@@ -249,6 +256,29 @@ function contrastPacketFromState(state: InferenceState): CandidateContrastPacket
|
||||
});
|
||||
}
|
||||
|
||||
function scoreableSnapshotCurrentFromDossier(
|
||||
dossier: DecisionDossier,
|
||||
options: { currentEvidenceFingerprint?: string | null } | undefined,
|
||||
inference: ReturnType<typeof previousInferenceFromReceipt>,
|
||||
): boolean {
|
||||
const latest = dossier.latestResult;
|
||||
if (!latest) return true;
|
||||
const stored = candidateSnapshotSource({
|
||||
birthProfileFingerprint: latest.candidateRangeFingerprint ?? "",
|
||||
scoreableEvidenceFingerprint: latest.evidenceLedgerFingerprint ?? "",
|
||||
inferenceRevision: inference?.revision ?? 0,
|
||||
candidateSetVersion: inference?.candidate_set_id ?? latest.resultId ?? "",
|
||||
scoringPolicyVersion: String(latest.policyVersion ?? latest.algorithmVersion ?? ""),
|
||||
});
|
||||
const currentFingerprint = options?.currentEvidenceFingerprint
|
||||
?? evidenceLedgerFingerprint(dossier.evidence as never);
|
||||
const current = candidateSnapshotSource({
|
||||
...stored,
|
||||
scoreableEvidenceFingerprint: currentFingerprint ?? "",
|
||||
});
|
||||
return storedSnapshotIsCurrent(stored, current);
|
||||
}
|
||||
|
||||
export function decideFromDossier(
|
||||
dossier: DecisionDossier,
|
||||
options?: { currentEvidenceFingerprint?: string | null },
|
||||
@@ -260,10 +290,8 @@ export function decideFromDossier(
|
||||
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
|
||||
sessionOutcome: "collect_evidence",
|
||||
});
|
||||
const storedFingerprint = dossier.latestResult?.evidenceLedgerFingerprint ?? "";
|
||||
const currentFingerprint = options?.currentEvidenceFingerprint ?? storedFingerprint;
|
||||
const snapshotCurrent = !storedFingerprint || storedFingerprint === currentFingerprint;
|
||||
const latest = dossier.latestResult;
|
||||
const snapshotCurrent = scoreableSnapshotCurrentFromDossier(dossier, options, inference);
|
||||
const confirmationGate = buildConfirmationGate({
|
||||
engineConfirmationAllowed: latest?.confirmationAllowed === true,
|
||||
candidates: (latest?.candidates ?? []).map((candidate) => ({
|
||||
|
||||
@@ -109,7 +109,7 @@ import { offerSessionKinds } from "@/lib/rectification-agentic/core/decide-next-
|
||||
import {
|
||||
candidateSnapshotSource,
|
||||
classifySnapshotStaleReason,
|
||||
scoreableSnapshotIsCurrent,
|
||||
storedSnapshotIsCurrent,
|
||||
SNAPSHOT_STALE_COPY,
|
||||
} from "@/lib/rectification-agentic/core/snapshot-source";
|
||||
import type { HoldoutValidationStatus } from "@/lib/rectification-agentic/core/decide-next-action";
|
||||
@@ -253,9 +253,7 @@ function safeCaseProjection(
|
||||
const separation = evaluateCandidateSeparation(candidateScores);
|
||||
const currentSnapshot = snapshotSourceFromDossier(dossier, compute);
|
||||
const storedSnapshot = latest ? storedSnapshotSource(latest) : null;
|
||||
const snapshotCurrent = !storedSnapshot
|
||||
|| !storedSnapshot.scoreableEvidenceFingerprint
|
||||
|| scoreableSnapshotIsCurrent(storedSnapshot, currentSnapshot);
|
||||
const snapshotCurrent = storedSnapshotIsCurrent(storedSnapshot, currentSnapshot);
|
||||
const collectingPlan = buildMethodFollowupPlan({
|
||||
evidence: dossier.evidence,
|
||||
activeFocus: dossier.conversationSummary.activeFocus,
|
||||
@@ -1886,8 +1884,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
const candidateScores = candidateScoresFromLatest(latest);
|
||||
const currentSnapshot = snapshotSourceFromDossier(parsed, null);
|
||||
const storedSnapshot = storedSnapshotSource(latest);
|
||||
const scoreableCurrent = !storedSnapshot.scoreableEvidenceFingerprint
|
||||
|| scoreableSnapshotIsCurrent(storedSnapshot, currentSnapshot);
|
||||
const scoreableCurrent = storedSnapshotIsCurrent(storedSnapshot, currentSnapshot);
|
||||
if (!scoreableCurrent) {
|
||||
throw new RectificationToolServiceError("offer_not_allowed");
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
classifySnapshotStaleReason,
|
||||
scoreableSnapshotIsCurrent,
|
||||
snapshotIsCurrent,
|
||||
storedSnapshotIsCurrent,
|
||||
type CandidateSnapshotSource,
|
||||
} from "../src/lib/rectification-agentic/core/snapshot-source.ts";
|
||||
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||||
@@ -461,6 +462,43 @@ test("stale reasons distinguish birth profile from scoreable evidence", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("missing scoreable fingerprint is stale, not current", () => {
|
||||
const current = source();
|
||||
assert.equal(scoreableSnapshotIsCurrent(source({ scoreableEvidenceFingerprint: "" }), current), false);
|
||||
assert.equal(scoreableSnapshotIsCurrent(current, source({ scoreableEvidenceFingerprint: "" })), false);
|
||||
assert.equal(
|
||||
classifySnapshotStaleReason(source({ scoreableEvidenceFingerprint: "" }), current),
|
||||
"fingerprint_missing",
|
||||
);
|
||||
assert.equal(storedSnapshotIsCurrent(null, current), true);
|
||||
assert.equal(storedSnapshotIsCurrent(source({ scoreableEvidenceFingerprint: "" }), current), false);
|
||||
assert.equal(storedSnapshotIsCurrent(current, current), true);
|
||||
});
|
||||
|
||||
test("stale snapshot keeps discrimination instead of returning to collection", () => {
|
||||
const next = decideNextAction({
|
||||
methodCoverageAll: true,
|
||||
trainingGateOpen: true,
|
||||
snapshotCurrent: false,
|
||||
candidateScores: TIED,
|
||||
discriminatorProbe: CONTRAST_PROBE,
|
||||
});
|
||||
assert.equal(next.type, "ask_candidate_discriminator");
|
||||
assert.equal(decideRectification({
|
||||
methodCoverageAll: true,
|
||||
trainingGateOpen: true,
|
||||
snapshotCurrent: false,
|
||||
candidateScores: TIED,
|
||||
discriminatorProbe: CONTRAST_PROBE,
|
||||
}).sessionOutcome, "discriminate_candidates");
|
||||
assert.equal(decideNextAction({
|
||||
methodCoverageAll: false,
|
||||
trainingGateOpen: false,
|
||||
snapshotCurrent: false,
|
||||
candidateScores: TIED,
|
||||
}).type, "ask_fact_collection");
|
||||
});
|
||||
|
||||
test("34/33/33 is a tie, not a recommended winner", () => {
|
||||
const separation = evaluateCandidateSeparation(TIED);
|
||||
assert.equal(separation.status, "not_separated");
|
||||
|
||||
@@ -255,6 +255,72 @@ const careerEvidence = {
|
||||
created_at: "2026-08-12T10:00:09.000Z",
|
||||
};
|
||||
|
||||
const methodCoverageTieEvidence = [
|
||||
{ ...educationEvidence, domain: "education" },
|
||||
{
|
||||
id: "44444444-4444-4444-8444-444444444442",
|
||||
source_turn_id: TURN_ID,
|
||||
subject: "self",
|
||||
event_kind: "relationship_start",
|
||||
domain: "relationship",
|
||||
occurred_from: "2018-01-01",
|
||||
occurred_to: null,
|
||||
date_precision: "year",
|
||||
summary: "感情变化",
|
||||
status: "confirmed",
|
||||
supersedes_evidence_id: null,
|
||||
created_at: "2026-08-12T10:00:07.000Z",
|
||||
},
|
||||
{
|
||||
id: "44444444-4444-4444-8444-444444444443",
|
||||
source_turn_id: TURN_ID,
|
||||
subject: "self",
|
||||
event_kind: "career_entry",
|
||||
domain: "career",
|
||||
occurred_from: "2019-01-01",
|
||||
occurred_to: null,
|
||||
date_precision: "year",
|
||||
summary: "工作变化",
|
||||
status: "confirmed",
|
||||
supersedes_evidence_id: null,
|
||||
created_at: "2026-08-12T10:00:08.000Z",
|
||||
},
|
||||
{
|
||||
id: "44444444-4444-4444-8444-444444444446",
|
||||
source_turn_id: TURN_ID,
|
||||
subject: "self",
|
||||
event_kind: "family_event",
|
||||
domain: "family",
|
||||
occurred_from: "2020-01-01",
|
||||
occurred_to: null,
|
||||
date_precision: "year",
|
||||
summary: "家人变化",
|
||||
status: "confirmed",
|
||||
supersedes_evidence_id: null,
|
||||
created_at: "2026-08-12T10:00:09.000Z",
|
||||
},
|
||||
{
|
||||
id: "44444444-4444-4444-8444-444444444445",
|
||||
source_turn_id: TURN_ID,
|
||||
subject: "self",
|
||||
event_kind: "occupation_note",
|
||||
domain: "occupation",
|
||||
occurred_from: null,
|
||||
occurred_to: null,
|
||||
date_precision: "unknown",
|
||||
summary: "长期一直是程序员",
|
||||
status: "confirmed",
|
||||
supersedes_evidence_id: null,
|
||||
created_at: "2026-08-12T10:00:10.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
function scoreableFingerprintForRawEvidence(evidence: unknown[]): string {
|
||||
const parsed = parseV9CaseDossier(dossierFixture({ evidence }));
|
||||
assert.ok(parsed);
|
||||
return evidenceLedgerFingerprint(parsed.evidence);
|
||||
}
|
||||
|
||||
function stubEngine(response: unknown) {
|
||||
const previous = globalThis.fetch;
|
||||
globalThis.fetch = (async () => ({
|
||||
@@ -2690,6 +2756,7 @@ test("offer-candidates refuses while method coverage remains", async () => {
|
||||
latestResult: candidateSnapshotFixture({
|
||||
selectionAllowed: true,
|
||||
representativeTime: "04:48",
|
||||
evidenceLedgerFingerprint: scoreableFingerprintForRawEvidence([educationEvidence]),
|
||||
candidates: [
|
||||
{ candidate_id: CANDIDATE_ID, rank: 1, time: "04:48", relative_support: 58, tied_minute_count: 1 },
|
||||
{ candidate_id: SECOND_CANDIDATE_ID, rank: 2, time: "04:49", relative_support: 42, tied_minute_count: 2 },
|
||||
@@ -2727,7 +2794,7 @@ test("paused case with selection_allowed may offer the escape hatch", async () =
|
||||
latestResult: candidateSnapshotFixture({
|
||||
selectionAllowed: true,
|
||||
representativeTime: "04:48",
|
||||
evidenceLedgerFingerprint: null,
|
||||
evidenceLedgerFingerprint: scoreableFingerprintForRawEvidence([educationEvidence]),
|
||||
candidates: [
|
||||
{ candidate_id: CANDIDATE_ID, rank: 1, time: "04:48", relative_support: 58, tied_minute_count: 1 },
|
||||
{ candidate_id: SECOND_CANDIDATE_ID, rank: 2, time: "04:49", relative_support: 42, tied_minute_count: 2 },
|
||||
@@ -2781,6 +2848,37 @@ test("paused case with selection_allowed may offer the escape hatch", async () =
|
||||
);
|
||||
});
|
||||
|
||||
test("offer-candidates refuses a stored snapshot with a missing fingerprint", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => dossierFixture({
|
||||
status: "paused",
|
||||
evidence: [educationEvidence],
|
||||
latestResult: candidateSnapshotFixture({
|
||||
selectionAllowed: true,
|
||||
representativeTime: "04:48",
|
||||
evidenceLedgerFingerprint: null,
|
||||
candidates: [
|
||||
{ candidate_id: CANDIDATE_ID, rank: 1, time: "04:48", relative_support: 58, tied_minute_count: 1 },
|
||||
{ candidate_id: SECOND_CANDIDATE_ID, rank: 2, time: "04:49", relative_support: 42, tied_minute_count: 2 },
|
||||
],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
const tools = createRectificationV9Tools({
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
turnId: TURN_ID,
|
||||
accounting: accounting.client as never,
|
||||
});
|
||||
await assert.rejects(
|
||||
() => (tools["rectification-offer-candidates"] as unknown as {
|
||||
execute(input: unknown): Promise<unknown>;
|
||||
}).execute({ caseId: CASE_ID }),
|
||||
(error: unknown) => error instanceof RectificationToolServiceError && error.code === "offer_not_allowed",
|
||||
);
|
||||
});
|
||||
|
||||
test("paused case resumes only when the Agent explicitly requests it", async () => {
|
||||
let reads = 0;
|
||||
const accounting = fakeAccounting({
|
||||
@@ -2819,69 +2917,11 @@ test("offer-candidates allows a 34/33/33 tie after method coverage when remainin
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => dossierFixture({
|
||||
evidence: [
|
||||
{ ...educationEvidence, domain: "education" },
|
||||
{
|
||||
id: "44444444-4444-4444-8444-444444444442",
|
||||
source_turn_id: TURN_ID,
|
||||
subject: "self",
|
||||
event_kind: "relationship_start",
|
||||
domain: "relationship",
|
||||
occurred_from: "2018-01-01",
|
||||
occurred_to: null,
|
||||
date_precision: "year",
|
||||
summary: "感情变化",
|
||||
status: "confirmed",
|
||||
supersedes_evidence_id: null,
|
||||
created_at: "2026-08-12T10:00:07.000Z",
|
||||
},
|
||||
{
|
||||
id: "44444444-4444-4444-8444-444444444443",
|
||||
source_turn_id: TURN_ID,
|
||||
subject: "self",
|
||||
event_kind: "career_entry",
|
||||
domain: "career",
|
||||
occurred_from: "2019-01-01",
|
||||
occurred_to: null,
|
||||
date_precision: "year",
|
||||
summary: "工作变化",
|
||||
status: "confirmed",
|
||||
supersedes_evidence_id: null,
|
||||
created_at: "2026-08-12T10:00:08.000Z",
|
||||
},
|
||||
{
|
||||
id: "44444444-4444-4444-8444-444444444446",
|
||||
source_turn_id: TURN_ID,
|
||||
subject: "self",
|
||||
event_kind: "family_event",
|
||||
domain: "family",
|
||||
occurred_from: "2020-01-01",
|
||||
occurred_to: null,
|
||||
date_precision: "year",
|
||||
summary: "家人变化",
|
||||
status: "confirmed",
|
||||
supersedes_evidence_id: null,
|
||||
created_at: "2026-08-12T10:00:09.000Z",
|
||||
},
|
||||
{
|
||||
id: "44444444-4444-4444-8444-444444444445",
|
||||
source_turn_id: TURN_ID,
|
||||
subject: "self",
|
||||
event_kind: "occupation_note",
|
||||
domain: "occupation",
|
||||
occurred_from: null,
|
||||
occurred_to: null,
|
||||
date_precision: "unknown",
|
||||
summary: "长期一直是程序员",
|
||||
status: "confirmed",
|
||||
supersedes_evidence_id: null,
|
||||
created_at: "2026-08-12T10:00:10.000Z",
|
||||
},
|
||||
],
|
||||
evidence: methodCoverageTieEvidence,
|
||||
latestResult: candidateSnapshotFixture({
|
||||
selectionAllowed: true,
|
||||
representativeTime: "05:00",
|
||||
evidenceLedgerFingerprint: null,
|
||||
evidenceLedgerFingerprint: scoreableFingerprintForRawEvidence(methodCoverageTieEvidence),
|
||||
decisionReceipt: {
|
||||
propose_allowed: true,
|
||||
window_scan: {
|
||||
|
||||
Reference in New Issue
Block a user