From 3d12f243b5e685834505ce5428b5af0edf0e6102 Mon Sep 17 00:00:00 2001 From: aihop <4408344+hhyykk@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:19:57 +0800 Subject: [PATCH] refactor(todos): remove retired summary column transport Signed-off-by: aihop <4408344+hhyykk@users.noreply.github.com> --- .../typescript-control-plane-migration-v0.md | 6 ++ ...script-control-plane-migration-v0.zh-CN.md | 5 ++ docs/reference/todo-work-counts.md | 6 ++ loopx/control_plane/todos/read_projection.ts | 13 +-- .../control_plane/todos/summary_projection.ts | 84 +++---------------- .../todo_summary_projection.test.ts | 78 ++++++++--------- 6 files changed, 70 insertions(+), 122 deletions(-) diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index 519d12ed6c..b3d827896b 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -150,6 +150,12 @@ without scalar RPCs; `todo.completion_state.require_metadata` are retired after their production callers exit. Existing semantic owners execute in process inside the transaction. +Native summary composition now passes rows and succession records directly. +The retired summary RPC's request versions, column codecs and unused handoff +carrier are removed. Full-source succession, field, frontier, lane and closure +checks stay with their existing owners; public read and summary outputs remain +compatible. + CLI query results carry a query-scoped `projection_envelope`. A read grants no execution, acceptance, lease or mutation authority. The shared wire and private snapshot budgets are unchanged. Oversized Python facts use an owned temporary diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md index 9875fdcc6d..9996cf3591 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md @@ -123,6 +123,11 @@ Python 保留 registry、文档/材料 IO、独立的 Vision-wait 消费者及 `todo.completion_state.normalize` 与 `todo.completion_state.require_metadata`。 事务在进程内执行既有语义 owner。 +原生摘要组合现在直接传递行对象与继任记录。 +已退役摘要 RPC 的请求版本、列式 codec 和无调用者的 handoff carrier 一并删除。 +完整来源的继任、字段、frontier、lane 与 closure 校验仍由既有 owner 负责; +公开读取与摘要输出保持兼容。 + CLI 查询结果携带查询范围内的 `projection_envelope`。读取不授予执行、验收、 租约或修改权限。共享 wire 与私有快照预算不变。超大 Python 事实使用私有临时 源,不增加持久化存储;完整结果通过摘要绑定的片段传输。 diff --git a/docs/reference/todo-work-counts.md b/docs/reference/todo-work-counts.md index fbc673c118..c53acec95a 100644 --- a/docs/reference/todo-work-counts.md +++ b/docs/reference/todo-work-counts.md @@ -107,6 +107,12 @@ The old `todo.summary.project` transport and Python ordinal materializer are retired. Large source/result transport preserves complete decisions and refuses changed continuations; display limits still disclose shown and available counts. +Native summary composition passes row objects and succession records directly. +Its obsolete request-version and column codecs, plus the unused summary handoff +carrier, are removed. Semantic validation remains with the existing owners. +The separate handoff-context interface and public read/summary outputs retain +their contracts. + Route-continuation visibility also uses the existing typed quota planning batch. It shares the normal claim/exclusion rule; eligibility, first-identity deduplication, sorting and counts precede the display cap. Disabled candidates do not hide later diff --git a/loopx/control_plane/todos/read_projection.ts b/loopx/control_plane/todos/read_projection.ts index a4a0c4d41d..146f92ef4e 100644 --- a/loopx/control_plane/todos/read_projection.ts +++ b/loopx/control_plane/todos/read_projection.ts @@ -8,9 +8,8 @@ import {requireJsonObject, requireBoolean} from "../runtime_decode.ts"; import {schedulerTimestampMilliseconds} from "../scheduler/state_store.ts"; import {evaluateTodoResumeConditionsFromSource} from "./resume_condition.ts"; import {readTodoPriority, todoPriorityRank} from "./priority.ts"; -import {evaluateTodoSuccession, validateTodoSuccession, SUCCESSION_FACT_COLUMNS, - SUCCESSION_EVALUATION_COLUMNS, SUCCESSION_CONTEXT_FIELDS} from "./succession.ts"; -import {projectTodoSummary, TODO_SUMMARY_PROJECTION_COLUMNS} from "./summary_projection.ts"; +import {evaluateTodoSuccession, validateTodoSuccession, SUCCESSION_CONTEXT_FIELDS} from "./succession.ts"; +import {projectTodoSummary} from "./summary_projection.ts"; import {projectTodoHandoffContext} from "./handoff_projection.ts"; import {projectStandingDecisions} from "./standing_decision.ts"; import {configuredGoalChecks} from "../goals/acceptance_contract.ts"; @@ -158,14 +157,8 @@ export async function projectTodoReadGroup(input: JsonObject): Promise summaryRow(item, decisions[i]!, preferred)); - const contexts = facts.map(row => row.context_fields!); const compacts = items.map(compactRecord); - const projected = projectTodoSummary({schema_version: "todo_summary_projection_request_v3", columns: [...TODO_SUMMARY_PROJECTION_COLUMNS], - rows: rows.map(row => TODO_SUMMARY_PROJECTION_COLUMNS.map(name => row[name]!)), - succession: {schema_version: "todo_succession_request_v1", row_columns: [...SUCCESSION_FACT_COLUMNS], - rows: facts.map((row, i) => SUCCESSION_FACT_COLUMNS.map(name => name === "context_fields" ? i : row[name]!)), - context_field_sets: contexts, evaluation_columns: [...SUCCESSION_EVALUATION_COLUMNS], - evaluations: decisions.map(row => SUCCESSION_EVALUATION_COLUMNS.map(name => row[name]!))}, + const projected = projectTodoSummary({rows, succession: {facts, evaluations: decisions}, frontier_rows: input.role === "agent" ? frontierFacts(items) : null, observed_at: input.observed_at!, selection: input.selection ?? null, role: input.role ?? null, source_section: input.source_section ?? null, item_limit: input.item_limit ?? null, full_selection: input.full_selection ?? true}); diff --git a/loopx/control_plane/todos/summary_projection.ts b/loopx/control_plane/todos/summary_projection.ts index 033cf0f034..59cfc923b1 100644 --- a/loopx/control_plane/todos/summary_projection.ts +++ b/loopx/control_plane/todos/summary_projection.ts @@ -1,4 +1,3 @@ -import {projectTodoHandoffContext} from "./handoff_projection.ts"; /** Whole-source summary decisions. The native read owner materializes public * display records; counts and closure never depend on a presentation budget. */ import type {JsonObject} from "../effect_program.ts"; @@ -7,7 +6,7 @@ import {requireBoolean, requireJsonObject, requireStringLiteral} from "../runtim import {parseTodoTimestampMicros} from "../runtime_timestamp.ts"; import {countDeferredTodoWork, projectTodoSummaryLanes, type TodoSummaryLane} from "./summary_lanes.ts"; import {projectSummaryFrontierIndex} from "./frontier_revision.ts"; -import {projectTodoClosure, projectTodoSuccession, SUCCESSION_FACT_COLUMNS, SUCCESSION_EVALUATION_COLUMNS} from "./succession.ts"; +import {projectTodoClosure, validateTodoSuccession} from "./succession.ts"; type Format = "raw" | "compact" | "active" | "recent" | "gap"; interface DisplayLane {indices: number[]; format: Format} @@ -18,57 +17,17 @@ interface SummaryProjection { fields: JsonObject; lanes: Record; orchestration: {candidate_items: number[]; user_blocker_items: number[]}; - handoff_context?: JsonObject[]; } -/** The declared order is this request version's schema, not a hint: the same - * cells in a different order would silently change meaning. */ -export const TODO_SUMMARY_PROJECTION_COLUMNS = [ - "status", "done", "task_class", "has_resume", "resume_ready", "resume_evaluated", - "acceptance_blocked", "claimed", "preferred", "watch_only", "due_at", "expires_at", - "sort", "completed_at", "updated_at", "completion_index", "linked_user_action", - "no_followup", "successor_gap", "handoff_state", "replan", "todo_id", "claim", - "bound", "blocks", "global", "excluded", -] as const; - -/** One whole-source batch carries every Todo, so the co-deployed adapter sends - * columnar facts. Decoding restores the row objects the lane and closure owners - * already validate; nothing is defaulted or inferred from absence. */ -function decodeRows(request: JsonObject): JsonObject[] { - const columns = request.columns; - if (!Array.isArray(columns) || columns.length !== TODO_SUMMARY_PROJECTION_COLUMNS.length || - columns.some((name, index) => name !== TODO_SUMMARY_PROJECTION_COLUMNS[index])) { - throw new EffectRuntimeRequestError("Todo summary row columns do not match the typed adapter order"); - } - if (!Array.isArray(request.rows)) { - throw new EffectRuntimeRequestError("Todo summary rows must be a list"); - } - return request.rows.map((value, ordinal) => { - if (!Array.isArray(value) || value.length !== columns.length) { - throw new EffectRuntimeRequestError(`Todo summary row ${ordinal} does not match its declared columns`); - } - return Object.fromEntries(TODO_SUMMARY_PROJECTION_COLUMNS.map((name, index) => [name, value[index]])); - }); -} - -/** Co-deployed callers can fuse evidence validation with summary projection. - * This is the same succession owner, not a cache, graph truncation or bypass. - * Legacy v1 callers retain their existing path; v2 requires the carrier. */ function validateSummarySuccession(request: JsonObject, rows: readonly JsonObject[]): void { - if (request.schema_version === "todo_summary_projection_request_v1") return; const source = requireJsonObject(request.succession, "summary succession"); - if (source.evaluations === undefined) { - throw new EffectRuntimeRequestError("summary requires full-source succession evaluations"); - } - const result = projectTodoSuccession(source); - const evaluations = result.evaluations as unknown[][]; - const facts = source.rows as unknown[][]; - if (evaluations.length !== rows.length) { + if (!Array.isArray(source.facts) || !Array.isArray(source.evaluations) || + source.facts.length !== rows.length || source.evaluations.length !== rows.length) { throw new EffectRuntimeRequestError("summary succession cardinality mismatch"); } for (const [index, row] of rows.entries()) { - const fact = Object.fromEntries(SUCCESSION_FACT_COLUMNS.map((name, column) => [name, facts[index][column]])); - const evaluation = Object.fromEntries(SUCCESSION_EVALUATION_COLUMNS.map((name, column) => [name, evaluations[index][column]])); + const fact = requireJsonObject(source.facts[index], "summary succession facts"); + const evaluation = validateTodoSuccession(fact, source.evaluations[index]); if (row.todo_id !== fact.todo_id || row.status !== fact.status || row.no_followup !== fact.no_followup || (row.task_class === "advancement_task") !== fact.advancement || row.successor_gap !== evaluation.successor_gap || row.handoff_state !== evaluation.handoff_state || @@ -102,11 +61,6 @@ function claimedVisibility(indices: readonly number[], rows: readonly JsonObject export function projectTodoSummary(value: unknown): SummaryProjection { const request = requireJsonObject(value, "Todo summary request"); - if (request.schema_version !== "todo_summary_projection_request_v1" && - request.schema_version !== "todo_summary_projection_request_v2" && - request.schema_version !== "todo_summary_projection_request_v3") { - throw new EffectRuntimeRequestError("Todo summary request schema mismatch"); - } const role = request.role === null ? null : requireStringLiteral(request.role, ["user", "agent"], "role"); if (request.source_section !== null && typeof request.source_section !== "string") { throw new EffectRuntimeRequestError("source_section must be text or null"); @@ -116,13 +70,11 @@ export function projectTodoSummary(value: unknown): SummaryProjection { throw new EffectRuntimeRequestError("item_limit must be a non-negative integer or null"); } const full = requireBoolean(request.full_selection, "full_selection"); - const rows = decodeRows(request); + if (!Array.isArray(request.rows)) throw new EffectRuntimeRequestError("Todo summary rows must be a list"); + const rows = request.rows.map(value => requireJsonObject(value, "Todo summary row")); validateSummarySuccession(request, rows); - if (request.handoff_sources !== undefined && (!Array.isArray(request.handoff_sources) || request.handoff_sources.length !== rows.length)) { - throw new EffectRuntimeRequestError("summary handoff cardinality mismatch"); - } - // The co-deployed adapter sends source facts, not prose or full Todo bodies. + // Source validation precedes every selection and display limit. for (const row of rows) { if ((row.claim !== null && typeof row.claim !== "string") || row.claimed !== Boolean(row.claim)) { throw new EffectRuntimeRequestError("summary claimant facts disagree"); @@ -154,12 +106,10 @@ export function projectTodoSummary(value: unknown): SummaryProjection { })), selected.deferred_items.length), }; const lanes: Record = {}; - if (request.schema_version === "todo_summary_projection_request_v3") { - if (role === "agent") { - fields.advancement_frontier_revision_index = projectSummaryFrontierIndex(request.frontier_rows, rows, source); - } else if (request.frontier_rows !== null) { - throw new EffectRuntimeRequestError("non-Agent summary must omit frontier source facts"); - } + if (role === "agent") { + fields.advancement_frontier_revision_index = projectSummaryFrontierIndex(request.frontier_rows, rows, source); + } else if (request.frontier_rows !== null) { + throw new EffectRuntimeRequestError("non-Agent summary must omit frontier source facts"); } const lane = (name: string, indices: readonly number[], cap: number | null = null, format: Format = "compact") => { lanes[name] = {indices: cap === null ? [...indices] : indices.slice(0, cap), format}; @@ -233,15 +183,7 @@ export function projectTodoSummary(value: unknown): SummaryProjection { Object.assign(fields, projectTodoClosure({schema_version: "todo_closure_request_v0", role, source_section: request.source_section, full_selection: fullSelection, rows: source.map(index => rows[index])})); return {schema_version: "todo_summary_projection_v0", - ...(request.handoff_sources === undefined ? {} : {handoff_context: projectTodoHandoffContext( - (request.handoff_sources as unknown[]).map((value, index) => { - const context = requireJsonObject(value, "summary handoff source"); - // Reuse the same normalized source facts, rather than sending another - // Todo identity/claim/exclusion/class copy for every historical row. - return {...context, metadata: {...requireJsonObject(context.metadata ?? {}, "handoff metadata"), - todo_id: rows[index].todo_id, claimed_by: rows[index].claim, excluded_agents: rows[index].excluded}, - texts: {...requireJsonObject(context.texts ?? {}, "handoff texts"), task_class: rows[index].task_class}}; - }))}), source_indices: source, full_selection: fullSelection, fields, lanes, + source_indices: source, full_selection: fullSelection, fields, lanes, orchestration: { candidate_items: role === "agent" ? selected.projected_open_items.filter(index => rows[index].task_class === "advancement_task") : [], user_blocker_items: role === "user" ? selected.projected_open_items.filter(index => rows[index].linked_user_action === true) : [], diff --git a/tests/control_plane_ts/todo_summary_projection.test.ts b/tests/control_plane_ts/todo_summary_projection.test.ts index 12c3b35ab0..ed1ce4dd6f 100644 --- a/tests/control_plane_ts/todo_summary_projection.test.ts +++ b/tests/control_plane_ts/todo_summary_projection.test.ts @@ -1,10 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; -import {TODO_SUMMARY_PROJECTION_COLUMNS, projectTodoSummary} from "../../loopx/control_plane/todos/summary_projection.ts"; +import {projectTodoSummary} from "../../loopx/control_plane/todos/summary_projection.ts"; import {projectAdvancementFrontier} from "../../loopx/control_plane/todos/frontier_revision.ts"; import {productionScaleCoordinationFixture} from "./production_scale_coordination_fixture.ts"; -import {evaluateTodoSuccession, SUCCESSION_FACT_COLUMNS, SUCCESSION_EVALUATION_COLUMNS} from "../../loopx/control_plane/todos/succession.ts"; +import {evaluateTodoSuccession} from "../../loopx/control_plane/todos/succession.ts"; const row = (fields: JsonObject = {}): JsonObject => ({status: "open", done: false, task_class: "advancement_task", has_resume: false, resume_ready: null, resume_evaluated: false, @@ -13,39 +13,31 @@ const row = (fields: JsonObject = {}): JsonObject => ({status: "open", done: fal blocks: null, global: false, excluded: [], completed_at: null, updated_at: null, completion_index: 0, linked_user_action: false, no_followup: false, successor_gap: false, handoff_state: null, replan: false, ...fields}); -const request = (rows: JsonObject[], fields: JsonObject = {}): JsonObject => ({ - schema_version: "todo_summary_projection_request_v1", - columns: [...TODO_SUMMARY_PROJECTION_COLUMNS], - rows: rows.map(row => TODO_SUMMARY_PROJECTION_COLUMNS.map(name => row[name] ?? null)), - observed_at: 100, - selection: null, role: "agent", source_section: "Agent Todo", item_limit: 12, full_selection: true, ...fields}); +const factsFor = (rows: JsonObject[]) => rows.map(row => ({todo_id: row.todo_id, + status: row.status, active: true, advancement: row.task_class === "advancement_task", + no_followup: row.no_followup, successors: [], superseded_by: null, unblocks: null, resumes: null, + handoff: row.handoff_state !== null, done: row.done, route_flag: row.replan, + legacy_route_label: "", context_fields: row.successor_gap ? ["action_kind"] : []})); +const request = (rows: JsonObject[], fields: JsonObject = {}): JsonObject => { + const facts = factsFor(rows); + return {rows, succession: {facts, evaluations: evaluateTodoSuccession(facts)}, + frontier_rows: rows.map(row => ({id: row.todo_id, claim: row.claim, excluded: row.excluded, + advancement: row.task_class === "advancement_task", + actionable: row.status === "open" && !row.done && (!row.has_resume || row.resume_ready === true) && !row.acceptance_blocked, + updated: row.updated_at || row.completed_at, serialized: JSON.stringify({todo_id: row.todo_id, status: row.status})})), + observed_at: 100, selection: null, role: "agent", source_section: "Agent Todo", + item_limit: 12, full_selection: true, ...fields}; +}; const done = (fields: JsonObject = {}) => row({status: "done", done: true, no_followup: true, ...fields}); function fused(rows: JsonObject[], facts: JsonObject[], evaluations = evaluateTodoSuccession(facts)): JsonObject { - return request(rows, {schema_version: "todo_summary_projection_request_v2", succession: { - schema_version: "todo_succession_request_v1", row_columns: [...SUCCESSION_FACT_COLUMNS], - context_field_sets: facts.map(fact => fact.context_fields), - rows: facts.map((fact, index) => SUCCESSION_FACT_COLUMNS.map(name => name === "context_fields" ? index : fact[name])), - evaluation_columns: [...SUCCESSION_EVALUATION_COLUMNS], - evaluations: evaluations.map(evaluation => SUCCESSION_EVALUATION_COLUMNS.map(name => evaluation[name])), - }}); -} - -function withFrontier(rows: JsonObject[]): JsonObject { - const facts = rows.map(row => ({todo_id: row.todo_id, status: row.status, active: true, - advancement: row.task_class === "advancement_task", no_followup: row.no_followup, - successors: [], superseded_by: null, unblocks: null, resumes: null, handoff: false, - done: row.done, route_flag: null, legacy_route_label: "", context_fields: []})); - return {...fused(rows, facts), schema_version: "todo_summary_projection_request_v3", - frontier_rows: rows.map(row => ({id: row.todo_id, claim: row.claim, excluded: row.excluded, - advancement: row.task_class === "advancement_task", actionable: row.status === "open", - updated: row.updated_at, serialized: JSON.stringify({todo_id: row.todo_id, status: row.status})}))}; + return request(rows, {succession: {facts, evaluations}}); } test("summary frontier uses selected full-source rows before caps and retains the existing index codec", () => { const rows = Array.from({length: 32}, (_, index) => row({todo_id: `todo_batch_${index}`, claimed: true, claim: index < 24 ? "a" : "b", updated_at: "2026-01-01T00:00:00.000001Z"})); - const carrier = withFrontier(rows), before = structuredClone(carrier); + const carrier = request(rows), before = structuredClone(carrier); const original = projectAdvancementFrontier({schema_version: "todo_frontier_revision_request_v0", operation: "index", rows: carrier.frontier_rows}).index; for (const limit of [null, 0, 1, 12]) { @@ -58,13 +50,12 @@ test("summary frontier uses selected full-source rows before caps and retains th assert.equal(selected.fields.total_count, 24); } assert.deepEqual(carrier, before); - assert.equal(projectTodoSummary(fused([], [])).fields.advancement_frontier_revision_index, undefined); - assert.deepEqual((projectTodoSummary(withFrontier([])).fields.advancement_frontier_revision_index as JsonObject).all, + assert.deepEqual((projectTodoSummary(request([])).fields.advancement_frontier_revision_index as JsonObject).all, {complete: false}); }); test("summary frontier rejects absent, reordered and conflicting facts instead of mixing snapshots", () => { - const carrier = withFrontier([row({todo_id: "todo_first", claimed: true, claim: "a", updated_at: "2026-01-01T00:00:00Z"}), + const carrier = request([row({todo_id: "todo_first", claimed: true, claim: "a", updated_at: "2026-01-01T00:00:00Z"}), row({todo_id: "todo_second", updated_at: "2026-01-01T00:00:00Z"})]); for (const mutate of [ (value: JsonObject) => { value.frontier_rows = null; }, @@ -81,7 +72,7 @@ test("summary frontier rejects absent, reordered and conflicting facts instead o assert.throws(() => projectTodoSummary({...carrier, role: "user"}), /non-Agent summary/); }); -test("fused summary preserves legacy decisions and full-source inferred edges through filtering", () => { +test("summary preserves full-source inferred edges through filtering", () => { const facts = [{todo_id: "todo_source", status: "done", active: true, advancement: true, no_followup: false, successors: [], superseded_by: null, unblocks: null, resumes: null, handoff: true, done: true, route_flag: null, legacy_route_label: "", context_fields: ["claimed_by"]}, @@ -92,7 +83,12 @@ test("fused summary preserves legacy decisions and full-source inferred edges th const rows = [done({todo_id: "todo_source", no_followup: false, handoff_state: "cleared_with_successor"})]; const carrier = fused(rows, facts.slice(0, 1), evaluations.slice(0, 1)); for (const limit of [null, 0, 1, 12]) { - assert.deepEqual(projectTodoSummary({...carrier, item_limit: limit}), projectTodoSummary(request(rows, {item_limit: limit}))); + const projected = projectTodoSummary({...carrier, item_limit: limit}); + assert.equal(projected.fields.done_count, 1); + assert.equal(projected.fields.todo_succession_warning, undefined); + assert.equal((projected.fields.terminal_closure_proof as JsonObject).item_count, 1); + assert.equal((projected.fields.terminal_closure_proof as JsonObject).successor_gap_count, 0); + assert.deepEqual(projected.source_indices, [0]); } const empty = projectTodoSummary(fused([], [])); assert.deepEqual(empty, projectTodoSummary(request([]))); @@ -108,19 +104,19 @@ test("fused summary refuses missing, stale, reordered and contradictory evidence assert.throws(() => projectTodoSummary({...carrier, succession: undefined}), /summary succession/); for (const mutate of [ (value: JsonObject) => { (value.succession as JsonObject).evaluations = []; }, - (value: JsonObject) => { ((value.succession as JsonObject).evaluations as unknown[][])[0][1] = "stale"; }, - (value: JsonObject) => { (value.rows as unknown[][]).reverse(); }, - (value: JsonObject) => { (value.rows as unknown[][])[0][TODO_SUMMARY_PROJECTION_COLUMNS.indexOf("successor_gap")] = true; }, - (value: JsonObject) => { (value.rows as unknown[][])[0][TODO_SUMMARY_PROJECTION_COLUMNS.indexOf("handoff_state")] = "blocking"; }, - (value: JsonObject) => { (value.rows as unknown[][])[0][TODO_SUMMARY_PROJECTION_COLUMNS.indexOf("no_followup")] = false; }, - (value: JsonObject) => { (value.rows as unknown[][])[0][TODO_SUMMARY_PROJECTION_COLUMNS.indexOf("replan")] = true; }, + (value: JsonObject) => { ((value.succession as JsonObject).evaluations as JsonObject[])[0].item_sha256 = "stale"; }, + (value: JsonObject) => { (value.rows as JsonObject[]).reverse(); }, + (value: JsonObject) => { (value.rows as JsonObject[])[0].successor_gap = true; }, + (value: JsonObject) => { (value.rows as JsonObject[])[0].handoff_state = "blocking"; }, + (value: JsonObject) => { (value.rows as JsonObject[])[0].no_followup = false; }, + (value: JsonObject) => { (value.rows as JsonObject[])[0].replan = true; }, ]) { const changed = structuredClone(carrier); mutate(changed); assert.throws(() => projectTodoSummary(changed)); } const changed = structuredClone(carrier); delete (changed.succession as JsonObject).evaluations; - assert.throws(() => projectTodoSummary(changed), /full-source succession/); + assert.throws(() => projectTodoSummary(changed), /summary succession cardinality/); }); test("one whole-source projection computes counts, visibility and closure before limits", () => { @@ -193,8 +189,8 @@ test("selection cannot turn partial source knowledge into a closure proof", () = }); test("scope preserves original ordinals and closure uses only the selected graph decisions", () => { - const rows = [row({todo_id: "peer", claimed: true, claim: "other"}), - done({todo_id: "own", claimed: true, claim: "me"})]; + const rows = [row({todo_id: "todo_peer", claimed: true, claim: "other"}), + done({todo_id: "todo_own", claimed: true, claim: "me"})]; const result = projectTodoSummary(request(rows, {selection: {role: "agent", status: null, todo_id: null, agent_id: "me"}})); assert.deepEqual(result.source_indices, [1]); assert.deepEqual(result.lanes.items.indices, [1]);