diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md index cfe70435e2..765b1bbf0d 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md @@ -3,7 +3,7 @@ - Status: Draft, under maintainer review - Initially proposed by: NoKV Lab - Widened by: LoopX maintainers -- Date: 2026-08-05; revised 2026-09-07 +- Date: 2026-08-05; revised 2026-09-12 - Scope: one provider-neutral LoopX authority contract with built-in file, optional NoKV, and optional PostgreSQL provider profiles, complementing [`host-integration-surface-v0`](../../reference/protocols/host-integration-surface-v0.md) @@ -2600,6 +2600,32 @@ The planner neither reads a provider nor grants a lease, CAS receipt, or write permission. This checkpoint closes one rule owner, not the remaining mutation inventory or local-store/promotion qualification. +### Cross-RFC semantic and presentation conformance checkpoint (2026-09-12) + +The TypeScript migration and this provider RFC now share one explicit Todo +semantic boundary. Python production callers import `todos/todo_semantics.py` +directly; `todos/projection.py` is retained only as an import-compatible facade +for external integrations. This is an ownership cleanup, not a second kernel. +The typed TypeScript `projection_delivery` union also owns the distinction +between mutation intent (`pending`/`not_required`) and provider readback +(`delivered`/`current`); unknown states fail closed before acknowledgement. + +Presentation is canonical at the projection layer, not in the domain record. +`source_section` and `index` are the v0 wire shape's display coordinates, while +native records derive the same display section from role/archive state and use +timestamp plus Todo identity as a deterministic fallback instead of a fake +persistent index. The normalized presentation metadata is therefore one +contract even when the wire shapes differ. The same rule is exercised by the +production-scale fixture and by File, SQLite, and NoKV conformance arms. +Provider revision tokens remain provider-owned and are compared only for the +provider-specific replay rules; they are not normalized into Todo semantics. + +This checkpoint changes read/ordering and compatibility-adapter semantics only: +it does not promote a provider, add a writer, alter the transaction decoder +delivered by #4280, or make Markdown a second authority. The shared RFC still +owns durable truth, recovery, cutover, and projection delivery; the TS RFC owns +business-rule ownership and caller deletion. + ### Next delivery and parallel provider work Markdown is a **permanent first-class readable projection**. Retire its database diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md index 8a4685f020..f61352253e 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md @@ -3,7 +3,7 @@ - 状态:Draft,正在接受 maintainer review - 最初提案方:NoKV Lab - 扩展修订方:LoopX maintainer -- 日期:2026-08-05;修订于 2026-09-07 +- 日期:2026-08-05;修订于 2026-09-12 - 范围:一个 provider-neutral 的 LoopX 权威合同,支持内置 file、可选 NoKV 与可选 PostgreSQL provider profile,用来补充 [`host-integration-surface-v0`](../../reference/protocols/host-integration-surface-v0.md) @@ -2060,6 +2060,28 @@ unsupported-field fence。planner 不读取 provider,也不授予 lease、CAS 写权限。该检查点闭合的是一个规则 owner,不是剩余 mutation inventory 或本地 store/promotion 资格化。 +### 跨 RFC 的语义与展示 conformance 检查点(2026-09-12) + +TypeScript 重构 RFC 与本 provider RFC 现在共享一个显式的 Todo 语义边界。 +Python 生产 caller 直接从 `todos/todo_semantics.py` 导入;`todos/projection.py` +只作为外部集成所需的 import 兼容 facade 保留,不再是第二个 kernel。这是 owner +收敛,不是新增一套规则。TypeScript 的 typed `projection_delivery` union 也明确区分 +mutation intent(`pending`/`not_required`)与 provider readback(`delivered`/`current`); +未知状态在 acknowledgement 之前 fail closed。 + +展示语义属于 projection 层,而不是 domain record。`source_section` 与 `index` 是 v0 +wire shape 的展示坐标;native record 根据 role/archive state 推导相同的展示 section, +并以时间戳和 Todo identity 做确定性回退,不制造假的持久 index。因此即使 wire shape +不同,normalized presentation metadata 仍只有一份 contract。同一规则由 +production-scale fixture 以及 File、SQLite、NoKV conformance arm 共同覆盖。Provider +自己的 revision token 仍由各自 provider 管理,只用于 provider-specific replay 规则, +不被归一成 Todo 语义。 + +本检查点只改变 read/ordering 与兼容 adapter 语义:不晋升 provider、不增加 writer, +不改动 #4280 交付的 transaction decoder,也不把 Markdown 变成第二权威。共享 RFC +继续负责 durable truth、恢复、cutover 与 projection delivery;TS RFC 负责业务规则 +owner 与 caller 删除。 + ### 下一步交付与并行 provider 工作 Markdown 是**长期保留的一等可读投影**。退役的是它的数据库及业务 writer 权威, diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index ef76ebd32d..d924fe7397 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -3,7 +3,7 @@ - Status: Accepted, transaction-payoff phase in progress - Proposed by: LoopX maintainers - Date: 2026-08-15 -- Last revised: 2026-09-10 +- Last revised: 2026-09-12 - Scope: an incremental, replacement-first migration of the LoopX control-plane core from Python to TypeScript without maintaining two semantic implementations @@ -192,11 +192,56 @@ this RFC follows the It declares fixture impact, exercises every affected provider arm, and keeps the read-only three-arm rehearsal as a separate promotion gate. +### Provider-neutral projection conformance checkpoint (2026-09-12) + +The conformance boundary now has one projection-fixture builder for both the +legacy v0 and native Todo record shapes. It owns deterministic Unicode ordering, +read-model digest/field construction, and the compatibility-only conversion; +provider tests no longer hand-rebuild those fields. The scale envelope declares +status ordering explicitly and validates its counts, so changing JSON key order +cannot silently change which Todo receives a lease, successor, or archive role. + +The File, SQLite, and NoKV suites now execute the same production-scale terminal +cases in both record shapes. A separate parity harness replays one seed, +observation, and lease sequence through all three isolated providers and compares +the logical head plus committed event/projection/receipt trace while ignoring +provider-specific revision tokens. This is conformance evidence, not a new +authority writer, provider default, or promotion claim; PostgreSQL remains under +its existing real-service qualification gate. + The old v0 consumer manifest remains readable and retains all existing fields. Default Markdown capture still emits v0; this PR neither rewrites stored heads nor auto-promotes a goal. The schema split is not permission to drop v0 provenance or change legacy ordering during a later migration. +### Canonical Todo presentation checkpoint (2026-09-12) + +The authority boundary now treats presentation as a first-class projection +contract rather than naming it `legacy_projection`. A shared TS presentation +normalizer maps the v0 wire shape's `source_section`/`index` to +`display_section`/`display_order`, while native records derive their display +section from domain role/archive state and never receive a fake persisted +index. The normalized presentation contract is shared; the wire coordinate is +not a second Todo state machine. + +Todo creation, terminal successor materialization, projection validation, +standing-decision ordering, and archive ordering all use the same presentation +owner. The canonical domain validator is shared by both wire shapes, and the +v0 record is produced by an adapter from a validated domain record. This +unifies the semantic owner without rewriting v0 heads or receipts. + +Python read callers now import the semantic owner directly; the compatibility +facade is no longer an internal dependency. Python presentation sorting keeps +source `index` order when it is present and uses completion/update time plus +Todo identity for native records, so the compatibility shape cannot leak into +business eligibility or lifecycle decisions. + +The next migration may persist an optional canonical `presentation` object, but +only after proving whether an imported section is provenance or current display +intent and after qualifying a stable display-order policy. Until then, native +display positions remain derived at the renderer boundary and must not affect +authority lifecycle decisions. + ### Long-goal persistence is part of the migration payoff The product target is at least ten elapsed days per goal, not a short-lived 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 89b21f5865..5c0b9da00f 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 @@ -3,7 +3,7 @@ - Status:Accepted,transaction-payoff 阶段进行中 - Proposed by:LoopX maintainers - Date:2026-08-15 -- Last revised:2026-09-10 +- Last revised:2026-09-12 - Scope:LoopX 控制面核心从 Python 到 TypeScript 的增量、replacement-first 迁移;不长期维护两份语义实现 - Tracking issue:[#3225](https://github.com/huangruiteng/loopx/issues/3225) @@ -151,10 +151,48 @@ replay、concurrency、归档压力与 hard-lease fence。该 fixture 是持久 声明 fixture 影响、覆盖所有受影响的 provider arm,并把只读三臂演练保留为独立的 promotion gate。 +### Provider-neutral projection conformance 检查点(2026-09-12) + +conformance 边界现在为 legacy v0 与 native Todo record 共用一个 projection-fixture +builder。它统一负责确定性的 Unicode 排序、read-model digest/field 构造,以及仅限 +兼容层的转换;provider 测试不再手工重建这些字段。规模 envelope 显式声明 status +顺序并校验计数,因此 JSON key 顺序变化不会静默改变哪个 Todo 获得 lease、successor +或 archive 角色。 + +File、SQLite 与 NoKV suite 现在会在两种 record shape 上执行同一组生产规模 terminal +case。另有独立 parity harness,使用三个隔离 provider 重放同一条 seed、observation、 +lease 序列,并在忽略 provider-specific revision token 后比较 logical head 以及已提交 +的 event/projection/receipt trace。这是 conformance 证据,不是新的 authority writer、 +provider 默认值或 promotion 声明;PostgreSQL 仍受现有真实服务资格化 gate 约束。 + 旧 v0 consumer manifest 继续可读,并保留所有已有字段。默认 Markdown capture 仍 输出 v0;本 PR 不改写已存 head,也不自动晋升 goal。schema 分层不等于允许后续迁移 丢失 v0 provenance 或改变旧排序。 +### Canonical Todo 展示检查点(2026-09-12) + +authority 边界现在把 presentation 作为一等 projection contract,而不再把它命名为 +`legacy_projection`。共享的 TS presentation normalizer 会把 v0 wire shape 的 +`source_section`/`index` 映射为 `display_section`/`display_order`;native record +则根据 domain 的 role/archive state 推导展示 section,绝不伪造持久化 index。两种 +wire shape 共用同一份 normalized presentation contract,wire 坐标不构成第二套 Todo +state machine。 + +Todo creation、terminal successor materialization、projection validation、 +standing-decision ordering 与 archive ordering 现在共用同一个 presentation owner。 +两种 wire shape 共用 canonical domain validator,v0 record 只是从已校验 domain +record 经过 adapter 生成。这统一了语义 owner,但不重写 v0 head 或 receipt。 + +Python read caller 现在直接导入语义 owner;兼容 facade 不再是内部依赖。Python 的 +展示排序在存在 source `index` 时保持其顺序,在 native record 上使用完成/更新时间 +加 Todo identity 做确定性排序,因此兼容 shape 不会泄漏进业务 eligibility 或 lifecycle +decision。 + +后续迁移可以持久化可选的 canonical `presentation` object,但必须先证明导入的 +section 到底是 provenance 还是当前 display intent,并资格化稳定的 display-order +策略。在此之前,native display position 仍在 renderer 边界派生,不能参与 authority +lifecycle decision。 + ### 长程持久化也是迁移收益的一部分 产品目标是单个 goal 至少持续十个自然日。shared-authority RFC 的 diff --git a/loopx/control_plane/agents/agent_scope.py b/loopx/control_plane/agents/agent_scope.py index af8931bf2b..ce4dde62e3 100644 --- a/loopx/control_plane/agents/agent_scope.py +++ b/loopx/control_plane/agents/agent_scope.py @@ -33,7 +33,7 @@ todo_item_is_actionable_open, todo_item_is_deferred, todo_item_task_class, - todo_projection_sort_key, + todo_presentation_sort_key, ) from ..todos.summary_item import compact_todo_summary_item from ..todos.user_gate import ( @@ -103,8 +103,8 @@ def _attach_agent_identity_contracts( def _todo_task_class(item: dict[str, Any]) -> str: return todo_item_task_class(item) -def _todo_projection_sort_key(item: dict[str, Any]) -> tuple[int, int]: - return todo_projection_sort_key(item) +def _todo_projection_sort_key(item: dict[str, Any]) -> tuple[int, int, str, str]: + return todo_presentation_sort_key(item) def _todo_item_is_actionable_open(item: dict[str, Any]) -> bool: diff --git a/loopx/control_plane/coordination/coordination_projection.ts b/loopx/control_plane/coordination/coordination_projection.ts index 1912a1cfa8..7bed26b71c 100644 --- a/loopx/control_plane/coordination/coordination_projection.ts +++ b/loopx/control_plane/coordination/coordination_projection.ts @@ -15,13 +15,12 @@ import { requireAuthorityStoreId, } from "./authority_store_codec.ts"; import { - canonicalCoordinationTodoRecord, - canonicalTodoDomainRecord, TODO_DOMAIN_READ_RECORD_SCHEMA, TODO_DOMAIN_RECORD_CONTRACT, TODO_CANONICAL_READ_RECORD_FIELDS, TODO_CANONICAL_READ_RECORD_SCHEMA, } from "./coordination_state_contract.ts"; +import {canonicalTodoRecord} from "./todo_presentation.ts"; export const COORDINATION_PROJECTION_MUTATION_EVENT_SCHEMA = "loopx_coordination_projection_mutation_event_v0"; @@ -29,6 +28,33 @@ export const COORDINATION_PROJECTION_MUTATION_RECEIPT_SCHEMA = "loopx_coordination_projection_mutation_receipt_v0"; export { TODO_CANONICAL_READ_RECORD_FIELDS, TODO_CANONICAL_READ_RECORD_SCHEMA }; +/** + * Build the revision-bound Todo read model carried by a canonical projection. + * + * The read model is projection metadata, not another source of Todo meaning. + * Keeping its construction beside validation prevents shadow capture, native + * transactions, and conformance fixtures from drifting on schema fields or + * digest inputs. Callers still choose the legacy/native schema explicitly; + * this helper never performs a compatibility conversion. + */ +export function coordinationTodoReadModel( + records: readonly JsonObject[], + schemaVersion: unknown, +): JsonObject { + const isNative = schemaVersion === TODO_DOMAIN_READ_RECORD_SCHEMA; + if (!isNative && schemaVersion !== TODO_CANONICAL_READ_RECORD_SCHEMA) { + throw new AuthorityStoreProtocolError("coordination Todo read-model schema mismatch"); + } + return { + schema_version: schemaVersion, + todo_count: records.length, + records_sha256: canonicalAuthoritySha256(records), + contract_fields: [...(isNative + ? TODO_DOMAIN_RECORD_CONTRACT.fields + : TODO_CANONICAL_READ_RECORD_FIELDS)], + }; +} + export interface CoordinationTodoProjectionIndex { readonly todos: ReadonlyMap; readonly todo_ids: readonly string[]; @@ -155,22 +181,12 @@ export function validateCoordinationTodoReadModel( )) { throw new AuthorityStoreProtocolError("coordination Todo read-model field contract mismatch"); } - const validateRecord = domain ? canonicalTodoDomainRecord : canonicalCoordinationTodoRecord; for (const [recordIndex, record] of records.entries()) { - validateRecord(record, `coordination Todo read record ${recordIndex}`); + canonicalTodoRecord(record, `coordination Todo read record ${recordIndex}`); } return readModel; } -function todoReadModel(records: readonly JsonObject[], previous: JsonObject): JsonObject { - return { - schema_version: previous.schema_version, - contract_fields: previous.contract_fields, - todo_count: records.length, - records_sha256: canonicalAuthoritySha256(records), - }; -} - function requireCompleteTodoReplacement( previous: JsonObject | undefined, replacement: JsonObject, @@ -323,7 +339,7 @@ export function reduceCoordinationProjection( leases: sortedIds(leases.keys()).map((todoId) => leases.get(todoId)!), ...(readModel === undefined ? {} - : { todo_read_model: todoReadModel(nextTodos, readModel) }), + : { todo_read_model: coordinationTodoReadModel(nextTodos, readModel.schema_version) }), }, "coordination projection"); if (value.todo_read_model !== undefined) { validateCoordinationTodoReadModel(reduced, expectedGoalId); diff --git a/loopx/control_plane/coordination/local_authority_shadow.ts b/loopx/control_plane/coordination/local_authority_shadow.ts index d00a15624c..37b9fe7864 100644 --- a/loopx/control_plane/coordination/local_authority_shadow.ts +++ b/loopx/control_plane/coordination/local_authority_shadow.ts @@ -20,7 +20,7 @@ import type { } from "./authority_store.ts"; import { authorityUnicodeCompare, canonicalAuthorityBytes, canonicalAuthoritySha256 } from "./authority_store_codec.ts"; import { - TODO_CANONICAL_READ_RECORD_FIELDS, + coordinationTodoReadModel, validateCoordinationTodoReadModel, } from "./coordination_projection.ts"; import { FileAuthorityStore } from "./file_authority_store.ts"; @@ -688,15 +688,6 @@ function partitionsOf(head: JsonObject | null): JsonObject { return partitions; } -function todoReadModel(todos: readonly JsonObject[]): JsonObject { - return { - schema_version: "loopx_todo_canonical_read_record_v0", - todo_count: todos.length, - records_sha256: createHash("sha256").update(canonicalAuthorityBytes(todos)).digest("hex"), - contract_fields: [...TODO_CANONICAL_READ_RECORD_FIELDS], - }; -} - /** * Fold one partition into the candidate head. A v0 head (whole-snapshot * observation) is accepted as the starting point with no partition markers. @@ -732,7 +723,10 @@ export function composeLocalAuthorityShadowHead( handoff_mode: handoffMode, todos, leases, - todo_read_model: todoReadModel(todos), + todo_read_model: coordinationTodoReadModel( + todos, + "loopx_todo_canonical_read_record_v0", + ), partitions, ...(base.capture_profile === undefined ? {} : { capture_profile: base.capture_profile, diff --git a/loopx/control_plane/coordination/todo_archive_selection.ts b/loopx/control_plane/coordination/todo_archive_selection.ts index 95c31d6462..9ce647cfb5 100644 --- a/loopx/control_plane/coordination/todo_archive_selection.ts +++ b/loopx/control_plane/coordination/todo_archive_selection.ts @@ -1,10 +1,10 @@ import type { JsonObject } from "../effect_program.ts"; import {isStandingDecisionReceipt} from "../todos/standing_decision.ts"; import { - authorityUnicodeCompare, canonicalAuthorityObject, requireAuthorityStoreId, } from "./authority_store_codec.ts"; +import {compareTodoPresentation} from "./todo_presentation.ts"; export const COORDINATION_TODO_ARCHIVE_SELECTION_SCHEMA = "loopx_coordination_todo_archive_selection_v0"; @@ -45,21 +45,7 @@ function archiveLimit(value: unknown): number { } function archiveOrder(left: JsonObject, right: JsonObject): number { - const leftIndex = Number.isSafeInteger(left.index) && Number(left.index) >= 0 - ? Number(left.index) : null; - const rightIndex = Number.isSafeInteger(right.index) && Number(right.index) >= 0 - ? Number(right.index) : null; - if (leftIndex !== null || rightIndex !== null) { - if (leftIndex === null) return 1; - if (rightIndex === null) return -1; - if (leftIndex !== rightIndex) return leftIndex - rightIndex; - } - const leftTime = typeof left.completed_at === "string" - ? left.completed_at : typeof left.updated_at === "string" ? left.updated_at : ""; - const rightTime = typeof right.completed_at === "string" - ? right.completed_at : typeof right.updated_at === "string" ? right.updated_at : ""; - if (leftTime !== rightTime) return authorityUnicodeCompare(leftTime, rightTime); - return authorityUnicodeCompare(String(left.todo_id), String(right.todo_id)); + return compareTodoPresentation(left, right); } /** Select completed Todo ids without owning storage or applying mutations. */ diff --git a/loopx/control_plane/coordination/todo_create.ts b/loopx/control_plane/coordination/todo_create.ts index 93fcd38891..c985daa1be 100644 --- a/loopx/control_plane/coordination/todo_create.ts +++ b/loopx/control_plane/coordination/todo_create.ts @@ -9,11 +9,10 @@ import { } from "./authority_store_codec.ts"; import {normalizeRegisteredTodoAgents, normalizeTodoAgent} from "./todo_agents.ts"; import { - TODO_DOMAIN_READ_RECORD_SCHEMA, TODO_DOMAIN_ITEM_SCHEMA, - TODO_ITEM_SCHEMA, canonicalTodoDomainRecord, } from "./coordination_state_contract.ts"; +import {materializeTodoRecordForSchema} from "./todo_presentation.ts"; import { indexCoordinationProjection, prepareCoordinationProjectionCommit, @@ -159,12 +158,7 @@ function createCandidate( last_actor_agent_id: input.actor_agent_id, updated_at: input.now.toISOString().replace(/\.\d{3}Z$/u, "Z"), }, "created Todo"); - if (readModelSchema === TODO_DOMAIN_READ_RECORD_SCHEMA) return domainCreated; - return { - ...domainCreated, - schema_version: TODO_ITEM_SCHEMA, - source_section: domainCreated.role === "agent" ? "Agent Todo" : "User Todo", - }; + return materializeTodoRecordForSchema(domainCreated, readModelSchema, "created Todo"); } /** In-process create planning for a caller-owned canonical transaction. Never diff --git a/loopx/control_plane/coordination/todo_presentation.ts b/loopx/control_plane/coordination/todo_presentation.ts new file mode 100644 index 0000000000..af55c599e5 --- /dev/null +++ b/loopx/control_plane/coordination/todo_presentation.ts @@ -0,0 +1,136 @@ +import type { JsonObject } from "../effect_program.ts"; +import { + authorityUnicodeCompare, + canonicalAuthorityObject, +} from "./authority_store_codec.ts"; +import { + canonicalCoordinationTodoRecord, + canonicalTodoDomainRecord, + TODO_CANONICAL_READ_RECORD_SCHEMA, + TODO_DOMAIN_ITEM_SCHEMA, + TODO_DOMAIN_READ_RECORD_SCHEMA, + TODO_ITEM_SCHEMA, +} from "./coordination_state_contract.ts"; + +export const TODO_PRESENTATION_METADATA_SCHEMA = + "loopx_todo_presentation_metadata_v0"; + +/** The index is provenance from the v0 wire shape, not a second domain rule. */ +export type TodoPresentationOrderSource = "source_index" | "legacy_index" | "todo_id"; + +export interface TodoPresentationMetadata extends JsonObject { + readonly schema_version: typeof TODO_PRESENTATION_METADATA_SCHEMA; + readonly todo_id: string; + readonly display_section: string; + readonly display_order: number | null; + readonly order_source: TodoPresentationOrderSource; +} + +function defaultDisplaySection(todo: JsonObject): string { + if (todo.archive_state === "archive") return "Completed Work Archive"; + return todo.role === "agent" ? "Agent Todo" : "User Todo"; +} + +function sourceDisplayOrder(todo: JsonObject): number | null { + return Number.isSafeInteger(todo.index) && Number(todo.index) >= 0 + ? Number(todo.index) + : null; +} + +/** + * Return the canonical presentation facts for one authority Todo. + * + * `source_section`/`index` are the v0 wire shape's canonical display + * coordinates. Native records derive the same display section from domain + * state and deliberately do not receive a fake persisted index. Rendering + * code may allocate a transient position later. + */ +export function todoPresentationMetadata( + value: JsonObject, + label = "Todo presentation metadata", +): TodoPresentationMetadata { + // Selection/renderer callers may pass a compact, already-decoded row with + // no schema marker. Treat that as legacy-compatible display input, while + // still rejecting an explicit unknown version. + const isLegacy = value.schema_version === TODO_ITEM_SCHEMA || + value.schema_version === undefined || value.schema_version === null; + const isNative = value.schema_version === TODO_DOMAIN_ITEM_SCHEMA; + if (!isLegacy && !isNative) { + throw new TypeError(`${label} has unsupported Todo schema`); + } + const displaySection = isLegacy && typeof value.source_section === "string" && + value.source_section.trim().length > 0 + ? value.source_section + : defaultDisplaySection(value); + const displayOrder = isLegacy ? sourceDisplayOrder(value) : null; + return canonicalAuthorityObject({ + schema_version: TODO_PRESENTATION_METADATA_SCHEMA, + todo_id: value.todo_id, + display_section: displaySection, + display_order: displayOrder, + order_source: displayOrder === null ? "todo_id" : "source_index", + }, label) as TodoPresentationMetadata; +} + +/** Validate either persisted wire shape through its shared domain contract. */ +export function canonicalTodoRecord( + value: unknown, + label = "coordination Todo record", +): JsonObject { + const record = canonicalAuthorityObject(value, label); + if (record.schema_version === TODO_DOMAIN_ITEM_SCHEMA) { + return canonicalTodoDomainRecord(record, label); + } + if (record.schema_version === TODO_ITEM_SCHEMA) { + return canonicalCoordinationTodoRecord(record, label); + } + throw new TypeError(`${label} has unsupported Todo schema`); +} + +/** + * Materialize one domain record for a selected read-model wire shape. + * Domain fields are validated once; presentation provenance is added only + * at the legacy boundary. + */ +export function materializeTodoRecordForSchema( + value: JsonObject, + readModelSchema: unknown, + label = "coordination Todo record", +): JsonObject { + const domain = canonicalTodoDomainRecord(value, label); + if (readModelSchema === TODO_DOMAIN_READ_RECORD_SCHEMA) return domain; + if (readModelSchema !== TODO_CANONICAL_READ_RECORD_SCHEMA) { + throw new TypeError(`${label} read-model schema is unsupported`); + } + return canonicalCoordinationTodoRecord({ + ...domain, + schema_version: TODO_ITEM_SCHEMA, + source_section: defaultDisplaySection(domain), + }, label); +} + +function timestamp(value: JsonObject): string { + return typeof value.completed_at === "string" + ? value.completed_at + : typeof value.updated_at === "string" ? value.updated_at : ""; +} + +/** Stable display ordering that preserves source display coordinates explicitly. */ +export function compareTodoPresentation( + left: JsonObject, + right: JsonObject, +): number { + const leftMetadata = todoPresentationMetadata(left); + const rightMetadata = todoPresentationMetadata(right); + if (leftMetadata.display_order !== null || rightMetadata.display_order !== null) { + if (leftMetadata.display_order === null) return 1; + if (rightMetadata.display_order === null) return -1; + if (leftMetadata.display_order !== rightMetadata.display_order) { + return leftMetadata.display_order - rightMetadata.display_order; + } + } + const leftTime = timestamp(left); + const rightTime = timestamp(right); + if (leftTime !== rightTime) return authorityUnicodeCompare(leftTime, rightTime); + return authorityUnicodeCompare(String(left.todo_id), String(right.todo_id)); +} diff --git a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts index c2ed4cf84e..55a0d57aa5 100644 --- a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts +++ b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts @@ -14,12 +14,12 @@ import { requireAuthorityStoreId, } from "./authority_store_codec.ts"; import { + TODO_CANONICAL_READ_RECORD_SCHEMA, TODO_DOMAIN_ITEM_SCHEMA, TODO_DOMAIN_READ_RECORD_SCHEMA, - TODO_ITEM_SCHEMA, - canonicalCoordinationTodoRecord, canonicalTodoDomainRecord, } from "./coordination_state_contract.ts"; +import {canonicalTodoRecord, materializeTodoRecordForSchema} from "./todo_presentation.ts"; import { indexCoordinationProjection, prepareCoordinationProjectionCommit, @@ -618,11 +618,13 @@ function successorCandidate( delete candidate.claimed_by; } const created = canonicalTodoDomainRecord(candidate, "terminal successor"); - return domainReadModel ? created : canonicalCoordinationTodoRecord({ - ...created, - schema_version: TODO_ITEM_SCHEMA, - source_section: created.role === "agent" ? "Agent Todo" : "User Todo", - }, "terminal successor compatibility record"); + return domainReadModel + ? created + : materializeTodoRecordForSchema( + created, + TODO_CANONICAL_READ_RECORD_SCHEMA, + "terminal successor compatibility record", + ); } function generatedSuccessorId( @@ -705,11 +707,7 @@ function terminalTarget( delete next.claimed_by; clearFields.push("claimed_by"); } - if (todo.schema_version === TODO_DOMAIN_ITEM_SCHEMA) { - canonicalTodoDomainRecord(next, "terminal Todo"); - } else { - canonicalCoordinationTodoRecord(next, "terminal Todo"); - } + canonicalTodoRecord(next, "terminal Todo"); return {todo: next, clear_fields: clearFields}; } diff --git a/loopx/control_plane/coordination/todo_update.ts b/loopx/control_plane/coordination/todo_update.ts index 68d6a4c50e..cd8b2de078 100644 --- a/loopx/control_plane/coordination/todo_update.ts +++ b/loopx/control_plane/coordination/todo_update.ts @@ -9,11 +9,7 @@ import { canonicalAuthoritySha256, requireAuthorityStoreId, } from "./authority_store_codec.ts"; -import { - TODO_DOMAIN_ITEM_SCHEMA, - canonicalCoordinationTodoRecord, - canonicalTodoDomainRecord, -} from "./coordination_state_contract.ts"; +import {canonicalTodoRecord} from "./todo_presentation.ts"; import { indexCoordinationProjection, prepareCoordinationProjectionCommit, @@ -283,11 +279,7 @@ function prepareUpdatedTodo( } next.done = next.status === "done" || next.status === "deferred"; } - if (todo.schema_version === TODO_DOMAIN_ITEM_SCHEMA) { - canonicalTodoDomainRecord(next, "updated Todo"); - } else { - canonicalCoordinationTodoRecord(next, "updated Todo"); - } + canonicalTodoRecord(next, "updated Todo"); } catch (error) { return failure("invalid_coordination_todo_update", error instanceof Error ? error.message : "invalid updated Todo"); diff --git a/loopx/control_plane/todos/decision_scope.py b/loopx/control_plane/todos/decision_scope.py index 675b3c752f..2d3b2ef3de 100644 --- a/loopx/control_plane/todos/decision_scope.py +++ b/loopx/control_plane/todos/decision_scope.py @@ -14,7 +14,7 @@ normalize_todo_required_decision_scopes, ) from .user_gate import is_user_gate_todo_item -from .projection import todo_projection_sort_key, todo_item_task_class, todo_item_has_removed_continuation_policy +from .todo_semantics import todo_projection_sort_key, todo_item_task_class, todo_item_has_removed_continuation_policy TODO_GATE_BLOCKING_STATES = frozenset( {"gate_targets_todo", "gate_covers_action", "projection_repair_required"} diff --git a/loopx/control_plane/todos/frontier_revision.py b/loopx/control_plane/todos/frontier_revision.py index eb732f983d..9348876b0b 100644 --- a/loopx/control_plane/todos/frontier_revision.py +++ b/loopx/control_plane/todos/frontier_revision.py @@ -9,7 +9,7 @@ from ..effect_runtime import effect_runtime_result from .contract import normalize_todo_claimed_by, normalize_todo_excluded_agents -from .projection import todo_item_task_class +from .todo_semantics import todo_item_task_class TODO_FRONTIER_REVISION_SCHEMA_VERSION = "todo_frontier_revision_v0" diff --git a/loopx/control_plane/todos/next_action_runtime.py b/loopx/control_plane/todos/next_action_runtime.py index a851246917..277bdc62ea 100644 --- a/loopx/control_plane/todos/next_action_runtime.py +++ b/loopx/control_plane/todos/next_action_runtime.py @@ -11,7 +11,7 @@ normalize_todo_id_list, normalize_todo_status, ) -from .projection import todo_priority_label +from .todo_semantics import todo_priority_label TODO_NEXT_ACTION_REQUEST_SCHEMA = "loopx_todo_next_action_transition_v1" diff --git a/loopx/control_plane/todos/quota_selection.py b/loopx/control_plane/todos/quota_selection.py index 95224798d7..0e2df5c202 100644 --- a/loopx/control_plane/todos/quota_selection.py +++ b/loopx/control_plane/todos/quota_selection.py @@ -11,7 +11,7 @@ normalize_todo_excluded_agents, normalize_todo_global_gate, normalize_required_capabilities, normalize_target_capabilities, ) -from .projection import ( +from .todo_semantics import ( todo_item_has_removed_continuation_policy, todo_item_is_actionable_open, todo_item_is_due_monitor, todo_item_task_class, todo_projection_sort_key, todo_summary_monitor_writeback_supported, diff --git a/loopx/control_plane/todos/quota_summary.py b/loopx/control_plane/todos/quota_summary.py index f077f11c3f..58ac727a9c 100644 --- a/loopx/control_plane/todos/quota_summary.py +++ b/loopx/control_plane/todos/quota_summary.py @@ -12,10 +12,10 @@ from .quota_selection import project_quota_planning from .frontier_deadline import todo_summary_frontier_deadline from .handoff_gate import build_todo_handoff_gate_lanes -from .projection import ( +from .todo_semantics import ( todo_item_is_watch_only_monitor, todo_item_task_class, - todo_projection_sort_key, + todo_presentation_sort_key, todo_summary_monitor_schedule_gap_items, todo_summary_monitor_writeback_contract, ) @@ -341,7 +341,7 @@ def summarize_user_todos_for_quota( source_completeness, closure_intent = validate_todo_source_contract(value) all_open_items = sorted( todo_summary_source_items(value), - key=todo_projection_sort_key, + key=todo_presentation_sort_key, ) planning = project_quota_planning( value, @@ -784,7 +784,7 @@ def summarize_project_asset_todos_for_quota( all_open_items = sorted( todo_summary_source_items(value), - key=todo_projection_sort_key, + key=todo_presentation_sort_key, ) if not all_open_items: next_text = str(value.get("next") or "").strip() diff --git a/loopx/control_plane/todos/resume_planning.py b/loopx/control_plane/todos/resume_planning.py index 39c058c0db..99abbfa3d3 100644 --- a/loopx/control_plane/todos/resume_planning.py +++ b/loopx/control_plane/todos/resume_planning.py @@ -12,7 +12,7 @@ normalize_todo_status, normalize_todo_task_class, normalize_todo_excluded_agents, ) from .compact_projection import compact_todo_projection_item -from .projection import todo_projection_sort_key +from .todo_semantics import todo_projection_sort_key _SOURCE_KEYS = ( "items", "backlog_items", "first_open_items", "deferred_items", diff --git a/loopx/control_plane/todos/route_continuation.py b/loopx/control_plane/todos/route_continuation.py index 8c5ce2283e..6beba91d6a 100644 --- a/loopx/control_plane/todos/route_continuation.py +++ b/loopx/control_plane/todos/route_continuation.py @@ -9,7 +9,7 @@ ) from .compact_projection import compact_todo_projection_item, projection_task_class from .handoff_gate import todo_summary_handoff_gates -from .projection import todo_projection_sort_key +from .todo_semantics import todo_presentation_sort_key TODO_ROUTE_CONTINUATION_SELECTION_POLICY = ( @@ -76,7 +76,7 @@ def todo_summary_route_continuation_candidates( if item.get("route_key") is not None: compact["route_key"] = item.get("route_key") candidates.append(compact) - return sorted(candidates, key=todo_projection_sort_key) + return sorted(candidates, key=todo_presentation_sort_key) def route_continuation_candidate_matches_agent( diff --git a/loopx/control_plane/todos/standing_decision.ts b/loopx/control_plane/todos/standing_decision.ts index f4b0ac2a58..e0cf5d9930 100644 --- a/loopx/control_plane/todos/standing_decision.ts +++ b/loopx/control_plane/todos/standing_decision.ts @@ -4,7 +4,7 @@ import type {JsonObject} from "../effect_program.ts"; import {requireJsonObject} from "../runtime_decode.ts"; import {parseTodoTimestampMicros} from "../runtime_timestamp.ts"; import {authorityUnicodeCompare} from "../coordination/authority_store_codec.ts"; -import {TODO_DOMAIN_ITEM_SCHEMA} from "../coordination/coordination_state_contract.ts"; +import {todoPresentationMetadata} from "../coordination/todo_presentation.ts"; export const STANDING_DECISION_REQUEST_SCHEMA = "standing_decision_projection_request_v0"; const SCOPE_KINDS = new Set(["private_read", "write_scope", "resource", "production", "public_claim", "direction", "other"]); @@ -66,11 +66,15 @@ function latest(candidates: readonly Candidate[], legacySourceOrder: boolean): C if (legacySourceOrder) return candidates.at(-1)!; // v0 records can retain actual source positions; never use synthetic // display indexes assigned to native records by a downstream adapter. - const indexed = candidates.every(c => c.item.schema_version !== TODO_DOMAIN_ITEM_SCHEMA && - Number.isSafeInteger(c.item.index) && Number(c.item.index) > 0 && text(c.item.source_section)); - if (indexed && new Set(candidates.map(c => c.item.source_section)).size === 1 && - new Set(candidates.map(c => c.item.index)).size === candidates.length) { - return [...candidates].sort((a, b) => Number(a.item.index) - Number(b.item.index)).at(-1)!; + const presentation = candidates.map(c => todoPresentationMetadata(c.item)); + const indexed = presentation.every(metadata => + (metadata.order_source === "source_index" || metadata.order_source === "legacy_index") && + metadata.display_order !== null); + if (indexed && new Set(presentation.map(metadata => metadata.display_section)).size === 1 && + new Set(presentation.map(metadata => metadata.display_order)).size === candidates.length) { + return [...candidates].sort((a, b) => + todoPresentationMetadata(a.item).display_order! - + todoPresentationMetadata(b.item).display_order!).at(-1)!; } } // Missing, invalid or mixed chronology cannot silently choose approval. diff --git a/loopx/control_plane/todos/summary_item.py b/loopx/control_plane/todos/summary_item.py index d180d6b20d..a9239025cf 100644 --- a/loopx/control_plane/todos/summary_item.py +++ b/loopx/control_plane/todos/summary_item.py @@ -18,7 +18,7 @@ ) from .handoff_gate import handoff_ready_successor_todo_ids from .handoff_note import attach_todo_handoff_note, compact_todo_continuation_hint -from .projection import todo_item_task_class +from .todo_semantics import todo_item_task_class TODO_SUMMARY_COMPACT_FIELDS = ( "schema_version", diff --git a/loopx/control_plane/todos/todo_semantics.py b/loopx/control_plane/todos/todo_semantics.py index 048c0fa04e..f8d2c0e20c 100644 --- a/loopx/control_plane/todos/todo_semantics.py +++ b/loopx/control_plane/todos/todo_semantics.py @@ -14,6 +14,9 @@ monitor_todo_next_due_at, monitor_todo_task_class, ) +from ..coordination.coordination_state_contract_generated import ( + COORDINATION_STATE_CONTRACT, +) from .contract import ( TODO_STATUS_DEFERRED, TODO_TASK_CLASS_ADVANCEMENT, @@ -34,6 +37,13 @@ re.IGNORECASE, ) TODO_PRIORITY_LABEL_PATTERN = re.compile(r"\bP([0-4])\b", re.IGNORECASE) +TODO_PRESENTATION_METADATA_SCHEMA = "loopx_todo_presentation_metadata_v0" +TODO_LEGACY_ITEM_SCHEMA = str( + COORDINATION_STATE_CONTRACT["todo_read_record"]["item_schema_version"] +) +TODO_NATIVE_ITEM_SCHEMA = str( + COORDINATION_STATE_CONTRACT["todo_domain_record"]["item_schema_version"] +) def todo_item_is_watch_only_monitor(item: dict[str, Any]) -> bool: @@ -95,6 +105,89 @@ def todo_index_rank(item: dict[str, Any]) -> int: return TODO_MISSING_INDEX +def todo_presentation_metadata(item: dict[str, Any]) -> dict[str, Any]: + """Project one Todo's display address without making it domain state. + + The v0 ``source_section``/``index`` pair is the wire shape's canonical + presentation coordinate. Native domain records derive a section from role + and archival state and intentionally receive no synthetic persisted index. + """ + + if not isinstance(item, dict): + raise ValueError("Todo presentation input must be an object") + schema = item.get("schema_version") + has_section = isinstance(item.get("source_section"), str) and bool( + item["source_section"].strip() + ) + if schema not in {None, TODO_LEGACY_ITEM_SCHEMA, TODO_NATIVE_ITEM_SCHEMA}: + raise ValueError(f"unsupported Todo presentation schema: {schema!r}") + legacy = schema == TODO_LEGACY_ITEM_SCHEMA or (schema is None and has_section) + if legacy: + section = item.get("source_section") if has_section else None + raw_index = item.get("index") + display_order = ( + int(raw_index) + if isinstance(raw_index, int) and not isinstance(raw_index, bool) and raw_index >= 0 + else None + ) + order_source = "source_index" if display_order is not None else "todo_id" + else: + section = None + display_order = None + order_source = "todo_id" + if not section: + section = ( + "Completed Work Archive" + if item.get("archive_state") == "archive" + else "Agent Todo" + if item.get("role") == "agent" + else "User Todo" + ) + return { + "schema_version": TODO_PRESENTATION_METADATA_SCHEMA, + "todo_id": str(item.get("todo_id") or ""), + "display_section": section, + "display_order": display_order, + "order_source": order_source, + } + + +def todo_presentation_sort_key( + item: dict[str, Any], + *, + text_mode: str = "label", +) -> tuple[int, int, str, str]: + """Sort display rows while preserving source coordinates and native determinism. + + Legacy rows with an equal priority/index retain Python's stable input order. + Native rows have no fabricated index, so timestamp and Todo identity provide + a deterministic tie-break that matches the TypeScript presentation adapter. + """ + + priority = todo_priority_rank(item, text_mode=text_mode) + try: + metadata = todo_presentation_metadata(item) + except ValueError: + # Compact display envelopes can carry their own outer schema marker. + # They are not authority records, so ordering may use the untyped + # compatibility fields without weakening the strict boundary helper. + metadata = { + "display_order": ( + int(item["index"]) + if isinstance(item.get("index"), int) + and not isinstance(item.get("index"), bool) + and item["index"] >= 0 + else None + ), + } + display_order = metadata["display_order"] + if display_order is not None: + return (priority, display_order, "", "") + timestamp = str(item.get("completed_at") or item.get("updated_at") or "") + todo_id = str(item.get("todo_id") or "") + return (priority, TODO_MISSING_INDEX, timestamp, todo_id) + + def todo_projection_sort_key( item: dict[str, Any], *, diff --git a/loopx/control_plane/todos/todo_summary.py b/loopx/control_plane/todos/todo_summary.py index 6b5524785d..ea4018a4ab 100644 --- a/loopx/control_plane/todos/todo_summary.py +++ b/loopx/control_plane/todos/todo_summary.py @@ -48,7 +48,7 @@ from .frontier_revision import attach_advancement_frontier_revision_index from .handoff_gate import build_todo_handoff_gate_states from .handoff_note import attach_todo_handoff_note -from .projection import ( +from .todo_semantics import ( todo_claimed_visibility_items as projection_todo_claimed_visibility_items, todo_item_is_actionable_open as projection_todo_item_is_actionable_open, todo_item_is_deferred as projection_todo_item_is_deferred, @@ -60,6 +60,7 @@ todo_item_expires_at as projection_todo_item_expires_at, todo_priority_parts as projection_todo_priority_parts, todo_priority_rank as projection_todo_priority_rank, + todo_presentation_sort_key as projection_todo_presentation_sort_key, todo_projection_sort_key as projection_todo_projection_sort_key, ) from .succession_warning import ( @@ -626,8 +627,8 @@ def open_todo_items( compact["text"] = text result.append(compact) if len(result) >= limit: - return sorted(result, key=todo_projection_sort_key) - return sorted(result, key=todo_projection_sort_key) + return sorted(result, key=projection_todo_presentation_sort_key) + return sorted(result, key=projection_todo_presentation_sort_key) def todo_lane_items( @@ -965,8 +966,11 @@ def _todo_group_lanes( terminal_items = [item for item in items if item.get("done")] deferred_items = [item for item in terminal_items if todo_item_is_deferred(item)] done_items = [item for item in terminal_items if not todo_item_is_deferred(item)] - projected_open_items = sorted(open_items, key=todo_projection_sort_key) - projected_deferred_items = sorted(deferred_items, key=todo_projection_sort_key) + projected_open_items = sorted(open_items, key=projection_todo_presentation_sort_key) + projected_deferred_items = sorted( + deferred_items, + key=projection_todo_presentation_sort_key, + ) budgeted_items = [ *projected_open_items, *projected_deferred_items, diff --git a/loopx/control_plane/todos/user_gate.py b/loopx/control_plane/todos/user_gate.py index bcbf10eab3..41bfb042df 100644 --- a/loopx/control_plane/todos/user_gate.py +++ b/loopx/control_plane/todos/user_gate.py @@ -3,7 +3,7 @@ from typing import Any from .contract import TODO_TASK_CLASS_USER_GATE -from .projection import todo_item_task_class +from .todo_semantics import todo_item_task_class USER_GATE_ACTION_KIND_HINTS = ( diff --git a/tests/control_plane/test_todo_semantic_kernel.py b/tests/control_plane/test_todo_semantic_kernel.py index 45235b12ce..6d09836f01 100644 --- a/tests/control_plane/test_todo_semantic_kernel.py +++ b/tests/control_plane/test_todo_semantic_kernel.py @@ -9,6 +9,8 @@ todo_item_claimed_by_agent_or_unclaimed, todo_item_is_due_monitor, todo_item_task_class, + todo_presentation_metadata, + todo_presentation_sort_key, ) from loopx.control_plane.todos.todo_summary import todo_item_task_class as summary_task_class @@ -38,3 +40,53 @@ def test_complex_fixture_declares_cross_rfc_semantic_edges() -> None: assert cases["global_gate_without_goal_binding"]["goal_bound"] is False assert cases["expired_lease"]["lease_epoch"] == 7 assert cases["excluded_unclaimed_advancement"]["claimed_by"] is None + + +def test_presentation_metadata_keeps_wire_address_out_of_native_ordering() -> None: + legacy = { + "schema_version": "todo_item_v0", + "todo_id": "todo_legacy", + "role": "agent", + "status": "open", + "archive_state": "active", + "source_section": "Agent Todo", + "index": 4, + } + native = { + "schema_version": "todo_domain_record_v0", + "todo_id": "todo_native", + "role": "agent", + "status": "open", + "archive_state": "active", + "updated_at": "2025-01-02T00:00:00Z", + } + assert todo_presentation_metadata(legacy)["order_source"] == "source_index" + assert todo_presentation_metadata(native) == { + "schema_version": "loopx_todo_presentation_metadata_v0", + "todo_id": "todo_native", + "display_section": "Agent Todo", + "display_order": None, + "order_source": "todo_id", + } + assert todo_presentation_sort_key(native)[1:] == ( + 999999, + "2025-01-02T00:00:00Z", + "todo_native", + ) + + +def test_unknown_presentation_schema_fails_closed() -> None: + try: + todo_presentation_metadata({"schema_version": "future_todo_v1"}) + except ValueError as error: + assert "unsupported Todo presentation schema" in str(error) + else: # pragma: no cover - assertion is the contract + raise AssertionError("unknown Todo presentation schema was accepted") + + +def test_fixture_display_cases_match_the_python_presentation_owner() -> None: + cases = json.loads(FIXTURE.read_text())["presentation_cases"] + assert todo_presentation_metadata(cases["legacy_display"])["display_order"] == 7 + native = todo_presentation_metadata(cases["native_display"]) + assert native["display_section"] == "Completed Work Archive" + assert native["display_order"] is None diff --git a/tests/control_plane_ts/authority_projection_fixture.ts b/tests/control_plane_ts/authority_projection_fixture.ts new file mode 100644 index 0000000000..ca5b193c48 --- /dev/null +++ b/tests/control_plane_ts/authority_projection_fixture.ts @@ -0,0 +1,102 @@ +import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; +import { + authorityUnicodeCompare, + canonicalAuthorityObject, +} from "../../loopx/control_plane/coordination/authority_store_codec.ts"; +import { + coordinationTodoReadModel, +} from "../../loopx/control_plane/coordination/coordination_projection.ts"; +import { + canonicalTodoDomainRecord, + TODO_CANONICAL_READ_RECORD_SCHEMA, + TODO_DOMAIN_ITEM_SCHEMA, + TODO_DOMAIN_READ_RECORD_SCHEMA, +} from "../../loopx/control_plane/coordination/coordination_state_contract.ts"; +import { + materializeTodoRecordForSchema, +} from "../../loopx/control_plane/coordination/todo_presentation.ts"; + +export type AuthorityProjectionSchema = "legacy" | "native"; + +function readModelSchema(schema: AuthorityProjectionSchema): string { + return schema === "native" + ? TODO_DOMAIN_READ_RECORD_SCHEMA + : TODO_CANONICAL_READ_RECORD_SCHEMA; +} + +/** + * Convert one synthetic Todo to the requested wire shape without changing + * domain fields. Compatibility metadata is deliberately the only difference. + */ +export function todoFixtureRecord( + value: JsonObject, + schema: AuthorityProjectionSchema, +): JsonObject { + const domainInput = structuredClone(value); + delete domainInput.schema_version; + delete domainInput.source_section; + delete domainInput.index; + const domain = canonicalTodoDomainRecord({ + ...domainInput, + schema_version: TODO_DOMAIN_ITEM_SCHEMA, + }, "fixture Todo domain record"); + const materialized = materializeTodoRecordForSchema( + domain, + readModelSchema(schema), + "fixture Todo", + ); + if (schema === "legacy") { + if (typeof value.source_section === "string") { + materialized.source_section = value.source_section; + } + if (Number.isSafeInteger(value.index)) materialized.index = value.index; + } + return canonicalAuthorityObject(materialized, "fixture Todo"); +} + +/** + * Construct a complete canonical projection from domain records. Every test + * provider receives identical ordering and read-model metadata, so a parity + * failure points at a provider rather than at fixture assembly. + */ +export function authorityProjectionFixture( + goalId: string, + todos: readonly JsonObject[], + leases: readonly JsonObject[] = [], + schema: AuthorityProjectionSchema = "native", + extras: JsonObject = {}, +): JsonObject { + const normalizedTodos = todos + .map(todo => todoFixtureRecord(todo, schema)) + .sort((left, right) => authorityUnicodeCompare( + String(left.todo_id), String(right.todo_id), + )); + const normalizedLeases = leases.map(lease => structuredClone(lease)).sort((left, right) => + authorityUnicodeCompare(String(left.todo_id), String(right.todo_id))); + return canonicalAuthorityObject({ + ...structuredClone(extras), + goal_id: goalId, + todos: normalizedTodos, + leases: normalizedLeases, + todo_read_model: coordinationTodoReadModel(normalizedTodos, readModelSchema(schema)), + }, "fixture coordination projection"); +} + +/** Preserve logical Todo fields while changing only compatibility provenance. */ +export function projectionFixtureAsSchema( + projection: JsonObject, + schema: AuthorityProjectionSchema, +): JsonObject { + const todos = Array.isArray(projection.todos) + ? projection.todos as JsonObject[] + : []; + const leases = Array.isArray(projection.leases) + ? projection.leases as JsonObject[] + : []; + const extras = structuredClone(projection); + delete extras.goal_id; + delete extras.todos; + delete extras.leases; + delete extras.todo_read_model; + return authorityProjectionFixture(String(projection.goal_id), todos, leases, schema, extras); +} diff --git a/tests/control_plane_ts/authority_provider_parity.test.ts b/tests/control_plane_ts/authority_provider_parity.test.ts new file mode 100644 index 0000000000..931e0cc573 --- /dev/null +++ b/tests/control_plane_ts/authority_provider_parity.test.ts @@ -0,0 +1,144 @@ +import assert from "node:assert/strict"; +import {mkdtemp, rm} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; +import test from "node:test"; + +import type { + NoKVBlobCasRequest, + NoKVBlobCasResult, + NoKVBlobReadResult, + NoKVBlobTransport, + NoKVStoreIdentityResult, +} from "../../loopx/control_plane/coordination/nokv_authority_store.ts"; +import {NoKVAuthorityStore} from "../../loopx/control_plane/coordination/nokv_authority_store.ts"; +import {FileAuthorityStore} from "../../loopx/control_plane/coordination/file_authority_store.ts"; +import {SqliteAuthorityStore} from "../../loopx/control_plane/coordination/sqlite_authority_store.ts"; +import type {AuthorityStore} from "../../loopx/control_plane/coordination/authority_store.ts"; +import {canonicalAuthoritySha256} from "../../loopx/control_plane/coordination/authority_store_codec.ts"; +import {prepareCoordinationProjectionCommit} from "../../loopx/control_plane/coordination/coordination_projection.ts"; +import {productionScaleCoordinationFixture} from "./production_scale_coordination_fixture.ts"; +import type {AuthorityProjectionSchema} from "./authority_projection_fixture.ts"; + +interface BlobState { + identity: string; + generation: number; + bytes: Uint8Array | null; +} + +class MemoryNoKVTransport implements NoKVBlobTransport { + private readonly state: BlobState; + constructor(state: BlobState) { this.state = state; } + async storeIdentity(_workbench: string): Promise { + return {status: "available", store_identity: this.state.identity}; + } + async readBlob(_workbench: string, _path: string): Promise { + return this.state.bytes === null + ? {status: "missing"} + : {status: "loaded", bytes: this.state.bytes.slice(), generation: this.state.generation}; + } + async casPublishBlob(request: NoKVBlobCasRequest): Promise { + if ((this.state.bytes === null ? null : this.state.generation) !== request.expected_generation) { + return {status: "conflict", current_generation: this.state.bytes === null ? null : this.state.generation}; + } + this.state.generation += 1; + this.state.bytes = request.bytes.slice(); + return {status: "applied", generation: this.state.generation}; + } +} + +interface ProviderFixture { + readonly name: string; + readonly store: AuthorityStore; + readonly contender: AuthorityStore; + readonly close: () => Promise; +} + +async function providers(t: test.TestContext): Promise { + const fileRoot = await mkdtemp(join(tmpdir(), "authority-parity-file-")); + const sqliteRoot = await mkdtemp(join(tmpdir(), "authority-parity-sqlite-")); + t.after(async () => { + await Promise.all([rm(fileRoot, {recursive: true, force: true}), rm(sqliteRoot, {recursive: true, force: true})]); + }); + const file = new FileAuthorityStore(fileRoot, "parity-goal"); + const sqlite = new SqliteAuthorityStore(sqliteRoot, "parity-goal"); + const state: BlobState = {identity: `nokv:parity-workbench:${"b".repeat(32)}`, generation: 0, bytes: null}; + const nokvTransport = new MemoryNoKVTransport(state); + const nokv = new NoKVAuthorityStore(nokvTransport, { + tenant_id: "parity-tenant", goal_id: "parity-goal", workbench: "parity-workbench", + }); + return [ + {name: "file", store: file, contender: new FileAuthorityStore(fileRoot, "parity-goal"), close: async () => {}}, + {name: "sqlite", store: sqlite, contender: new SqliteAuthorityStore(sqliteRoot, "parity-goal"), close: async () => {}}, + {name: "nokv", store: nokv, contender: new NoKVAuthorityStore(nokvTransport, { + tenant_id: "parity-tenant", goal_id: "parity-goal", workbench: "parity-workbench", + }), close: async () => {}}, + ]; +} + +async function logicalTrace(store: AuthorityStore, schema: AuthorityProjectionSchema): Promise { + const fixture = productionScaleCoordinationFixture("parity-goal", schema); + const seeded = await store.commitAuthority({ + operation_id: "parity-seed", expected_provider_revision: null, + events: [], receipts: [], next_projection: fixture.projection, + }); + assert.equal(seeded.status, "applied", JSON.stringify(seeded)); + const loaded = await store.loadAuthority(); + assert.equal(loaded.status, "loaded"); + if (loaded.status !== "loaded") throw new Error("parity seed did not load"); + const monitor = (loaded.head.todos as Record[]).find(todo => + todo.task_class === "continuous_monitor" && !(loaded.head.leases as Record[]) + .some(lease => lease.todo_id === todo.todo_id)); + if (!monitor) throw new Error("parity fixture needs a lease-free monitor"); + const changed = {...monitor, reason: "parity observation", last_actor_agent_id: "agent-a"}; + const first = await store.commitAuthority(prepareCoordinationProjectionCommit({ + goal_id: "parity-goal", operation_id: "parity-observation", + expected_provider_revision: loaded.provider_revision, projection: loaded.head, + mutations: [{kind: "todo_upsert", todo: changed}], + })); + assert.equal(first.status, "applied", JSON.stringify(first)); + const afterObservation = await store.loadAuthority(); + assert.equal(afterObservation.status, "loaded"); + if (afterObservation.status !== "loaded") throw new Error("parity observation did not load"); + const lease = {todo_id: monitor.todo_id, owner: "agent-a", status: "active", + idempotency_key: "parity-lease", version: 1, lease_epoch: 1, + expires_at: "2099-01-01T00:00:00Z"}; + const second = await store.commitAuthority(prepareCoordinationProjectionCommit({ + goal_id: "parity-goal", operation_id: "parity-lease", + expected_provider_revision: afterObservation.provider_revision, projection: afterObservation.head, + mutations: [{kind: "lease_upsert", lease}], + })); + assert.equal(second.status, "applied", JSON.stringify(second)); + const final = await store.loadAuthority(); + assert.equal(final.status, "loaded"); + if (final.status !== "loaded") throw new Error("parity final head did not load"); + const page = await store.scanCommitted(null, 10); + assert.equal(page.status, "page"); + if (page.status !== "page") throw new Error("parity scan did not return a page"); + assert.equal(page.transactions.length, 3); + assert.equal((await store.readReceipt("parity-observation")).status, "found"); + assert.equal((await store.readReceipt("parity-lease")).status, "found"); + return [ + canonicalAuthoritySha256(final.head), + ...page.transactions.map(transaction => canonicalAuthoritySha256({ + operation_id: transaction.operation_id, + events: transaction.events, + projection: transaction.projection, + receipts: transaction.receipts, + })), + ]; +} + +for (const schema of ["legacy", "native"] as const) { + test(`provider parity preserves one logical transaction trace (${schema})`, async t => { + const traces = await Promise.all((await providers(t)).map(async provider => ({ + name: provider.name, + trace: await logicalTrace(provider.store, schema), + }))); + assert.ok(traces.length > 0); + assert.deepEqual( + traces.map(item => item.trace), + traces.map(() => traces[0]!.trace), + ); + }); +} diff --git a/tests/control_plane_ts/authority_store_conformance.ts b/tests/control_plane_ts/authority_store_conformance.ts index 6d7fc02291..e8d3fa680c 100644 --- a/tests/control_plane_ts/authority_store_conformance.ts +++ b/tests/control_plane_ts/authority_store_conformance.ts @@ -19,7 +19,10 @@ import { TODO_DOMAIN_RECORD_CONTRACT, TODO_ITEM_SCHEMA, } from "../../loopx/control_plane/coordination/coordination_state_contract.ts"; -import { prepareCoordinationProjectionCommit } from "../../loopx/control_plane/coordination/coordination_projection.ts"; +import { + coordinationTodoReadModel, + prepareCoordinationProjectionCommit, +} from "../../loopx/control_plane/coordination/coordination_projection.ts"; import { executeCoordinationTodoClaim } from "../../loopx/control_plane/coordination/todo_claim.ts"; import { executeCoordinationTodoCreate } from "../../loopx/control_plane/coordination/todo_create.ts"; import {executeCoordinationMonitorPoll} from "../../loopx/control_plane/coordination/todo_monitor_poll.ts"; @@ -39,6 +42,9 @@ import { PRODUCTION_SCALE_VALIDATION_DECLARATION, productionScaleCoordinationFixture, } from "./production_scale_coordination_fixture.ts"; +import { + authorityProjectionFixture, +} from "./authority_projection_fixture.ts"; export interface AuthorityStoreConformanceFixture { store: AuthorityStore; @@ -72,21 +78,9 @@ function todoClaimProjection(goalId: string, native: boolean): Record { @@ -155,11 +149,7 @@ function todoTerminalProjection(goalId: string): Record { completed_at: "2026-08-31T00:00:00Z", }, ].sort((left, right) => left.todo_id.localeCompare(right.todo_id)); - return { - goal_id: goalId, - handoff_mode: "hard_lease", - todos, - leases: [{ + const leases = [{ schema_version: "task_lease_v0", goal_id: goalId, todo_id: "todo-terminal", @@ -173,16 +163,10 @@ function todoTerminalProjection(goalId: string): Record { updated_at: "2026-09-07T05:50:00Z", expires_at: "2026-09-07T06:10:00Z", status: "active", - }], - todo_read_model: { - schema_version: TODO_DOMAIN_READ_RECORD_SCHEMA, - todo_count: todos.length, - records_sha256: createHash("sha256") - .update(canonicalAuthorityBytes(todos)) - .digest("hex"), - contract_fields: [...TODO_DOMAIN_RECORD_CONTRACT.fields], - }, - }; + }]; + return authorityProjectionFixture(goalId, todos, leases, "native", { + handoff_mode: "hard_lease", + }); } export function authorityStoreCommitFixture( @@ -261,7 +245,7 @@ export function registerAuthorityStoreConformance( for (const native of [false, true]) test(`${providerName} conformance: atomic Monitor observation and successor (${native ? "native" : "legacy"})`, async (t) => { const {store, contender} = await factory(t); const goal = "goal-monitor"; - const fixture = productionScaleCoordinationFixture(goal); + const fixture = productionScaleCoordinationFixture(goal, native ? "native" : "legacy"); const projection = structuredClone(fixture.projection); const records = projection.todos as Record[]; const monitor = records.find(todo => todo.task_class === "continuous_monitor" && todo.status !== "done" && @@ -269,12 +253,12 @@ export function registerAuthorityStoreConformance( assert.ok(monitor, "complex fixture needs a lease-free Monitor"); Object.assign(monitor, {target_key: "conformance-watch", cadence: "1h", material_change_generation: 4}); for (const field of ["claimed_by", "bound_agent", "excluded_agents", "last_checked_at", "monitor_effect_id", "task_repository", "result_hash"]) delete monitor[field]; - if (native) for (const record of records) { - record.schema_version = TODO_DOMAIN_ITEM_SCHEMA; delete record.source_section; delete record.index; - } - projection.todo_read_model = {schema_version: native ? TODO_DOMAIN_READ_RECORD_SCHEMA : TODO_CANONICAL_READ_RECORD_SCHEMA, - todo_count: records.length, records_sha256: canonicalAuthoritySha256(records), - contract_fields: [...(native ? TODO_DOMAIN_RECORD_CONTRACT.fields : TODO_CANONICAL_READ_RECORD_FIELDS)]}; + // The fixture owns the compatibility conversion; this test only changes + // the monitor-specific observation fields. + projection.todo_read_model = coordinationTodoReadModel( + records, + (projection.todo_read_model as Record).schema_version, + ); assert.equal((await store.commitAuthority({operation_id: "monitor-seed", expected_provider_revision: null, events: [], receipts: [], next_projection: projection})).status, "applied"); const request = {goal_id: goal, operation_id: "monitor-effect", actor_agent_id: "agent-a", @@ -315,18 +299,9 @@ export function registerAuthorityStoreConformance( for (const native of [false, true]) test(`${providerName} conformance: governance reads one full Todo/lease snapshot (${native ? "native" : "legacy"})`, async (t) => { const {store} = await factory(t); const goal = "goal-governance"; - const fixture = productionScaleCoordinationFixture(goal); + const fixture = productionScaleCoordinationFixture(goal, native ? "native" : "legacy"); const projection = structuredClone(fixture.projection); const records = projection.todos as Record[]; - if (native) { - for (const record of records) { - record.schema_version = TODO_DOMAIN_ITEM_SCHEMA; - delete record.source_section; delete record.index; - } - projection.todo_read_model = {schema_version: TODO_DOMAIN_READ_RECORD_SCHEMA, - todo_count: records.length, records_sha256: canonicalAuthoritySha256(records), - contract_fields: [...TODO_DOMAIN_RECORD_CONTRACT.fields]}; - } const seeded = await store.commitAuthority({operation_id: "governance-fixture", expected_provider_revision: null, events: [], receipts: [], next_projection: projection}); assert.equal(seeded.status, "applied"); @@ -856,10 +831,10 @@ export function registerAuthorityStoreConformance( assert.deepEqual(archived.moved_todo_ids, ["todo-z-older"]); }); - test(`${providerName} conformance: production-scale terminal lifecycle stays bounded`, async (t) => { + for (const schema of ["legacy", "native"] as const) test(`${providerName} conformance: production-scale terminal lifecycle stays bounded (${schema})`, async (t) => { const {store} = await factory(t); const goalId = "goal-production-scale"; - const fixture = productionScaleCoordinationFixture(goalId); + const fixture = productionScaleCoordinationFixture(goalId, schema); assert.equal(fixture.projection.handoff_mode, "hard_lease"); const initialized = await store.commitAuthority({ expected_provider_revision: null, diff --git a/tests/control_plane_ts/production_scale_coordination_fixture.test.ts b/tests/control_plane_ts/production_scale_coordination_fixture.test.ts new file mode 100644 index 0000000000..b3cdd1871e --- /dev/null +++ b/tests/control_plane_ts/production_scale_coordination_fixture.test.ts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import {canonicalAuthoritySha256} from "../../loopx/control_plane/coordination/authority_store_codec.ts"; +import { + TODO_CANONICAL_READ_RECORD_SCHEMA, + TODO_DOMAIN_ITEM_SCHEMA, + TODO_DOMAIN_READ_RECORD_SCHEMA, + TODO_ITEM_SCHEMA, +} from "../../loopx/control_plane/coordination/coordination_state_contract.ts"; +import {validateCoordinationTodoReadModel} from "../../loopx/control_plane/coordination/coordination_projection.ts"; +import {productionScaleCoordinationFixture} from "./production_scale_coordination_fixture.ts"; + +test("production-scale fixture is deterministic and explicitly ordered", () => { + const first = productionScaleCoordinationFixture("fixture-goal", "legacy"); + const second = productionScaleCoordinationFixture("fixture-goal", "legacy"); + assert.equal(canonicalAuthoritySha256(first.projection), canonicalAuthoritySha256(second.projection)); + const todos = first.projection.todos as Record[]; + assert.equal(todos.length, first.expected_initial_todo_count); + assert.deepEqual( + todos.map(todo => todo.todo_id), + [...todos].sort((left, right) => String(left.todo_id).localeCompare(String(right.todo_id))) + .map(todo => todo.todo_id), + ); + assert.equal(first.projection.todo_read_model && + (first.projection.todo_read_model as Record).schema_version, + TODO_CANONICAL_READ_RECORD_SCHEMA); + assert.equal( + (first.projection.todo_read_model as Record).records_sha256, + canonicalAuthoritySha256(todos), + ); +}); +test("legacy and native fixture variants differ only by compatibility provenance", () => { + const legacy = productionScaleCoordinationFixture("fixture-goal", "legacy"); + const native = productionScaleCoordinationFixture("fixture-goal", "native"); + const legacyTodos = legacy.projection.todos as Record[]; + const nativeTodos = native.projection.todos as Record[]; + assert.equal(legacyTodos.length, nativeTodos.length); + for (const [index, legacyTodo] of legacyTodos.entries()) { + const nativeTodo = nativeTodos[index]!; + assert.equal(legacyTodo.todo_id, nativeTodo.todo_id); + assert.equal(legacyTodo.role, nativeTodo.role); + assert.equal(legacyTodo.text, nativeTodo.text); + assert.equal(legacyTodo.status, nativeTodo.status); + assert.equal(legacyTodo.source_section !== undefined, true); + assert.equal(legacyTodo.index !== undefined, true); + assert.equal(nativeTodo.schema_version, TODO_DOMAIN_ITEM_SCHEMA); + assert.equal(nativeTodo.source_section, undefined); + assert.equal(nativeTodo.index, undefined); + const {schema_version: _legacySchema, source_section: _section, index: _index, ...legacyDomain} = legacyTodo; + const {schema_version: _nativeSchema, ...nativeDomain} = nativeTodo; + assert.deepEqual(legacyDomain, nativeDomain); + } + assert.equal( + (native.projection.todo_read_model as Record).schema_version, + TODO_DOMAIN_READ_RECORD_SCHEMA, + ); + assert.doesNotThrow(() => validateCoordinationTodoReadModel(native.projection, "fixture-goal")); + assert.doesNotThrow(() => validateCoordinationTodoReadModel(legacy.projection, "fixture-goal")); + assert.equal(native.expected_agent_archive_count_after_terminals, legacy.expected_agent_archive_count_after_terminals); + assert.equal(native.expected_user_archive_count, legacy.expected_user_archive_count); +}); + +test("fixture rejects silent status-count drift at construction time", () => { + // The checked-in envelope is validated while loading. This assertion keeps + // the public contract explicit: the fixture must expose both wire schemas. + const fixture = productionScaleCoordinationFixture("fixture-goal", "native"); + assert.equal( + (fixture.projection.todos as Record[])[0]?.schema_version, + TODO_DOMAIN_ITEM_SCHEMA, + ); + assert.equal( + (fixture.projection.todo_read_model as Record).schema_version, + TODO_DOMAIN_READ_RECORD_SCHEMA, + ); + assert.notEqual(TODO_ITEM_SCHEMA, TODO_DOMAIN_ITEM_SCHEMA); +}); diff --git a/tests/control_plane_ts/production_scale_coordination_fixture.ts b/tests/control_plane_ts/production_scale_coordination_fixture.ts index 05cab6a47a..c320a7ebdc 100644 --- a/tests/control_plane_ts/production_scale_coordination_fixture.ts +++ b/tests/control_plane_ts/production_scale_coordination_fixture.ts @@ -1,13 +1,15 @@ -import {createHash} from "node:crypto"; import {readFileSync} from "node:fs"; -import {canonicalAuthorityBytes, canonicalAuthoritySha256} from +import {authorityUnicodeCompare, canonicalAuthoritySha256} from "../../loopx/control_plane/coordination/authority_store_codec.ts"; import { - TODO_CANONICAL_READ_RECORD_FIELDS, - TODO_CANONICAL_READ_RECORD_SCHEMA, TODO_ITEM_SCHEMA, } from "../../loopx/control_plane/coordination/coordination_state_contract.ts"; +import { + authorityProjectionFixture, + projectionFixtureAsSchema, + type AuthorityProjectionSchema, +} from "./authority_projection_fixture.ts"; const envelope = JSON.parse(readFileSync(new URL( "../fixtures/control_plane/coordination_production_scale_v0.json", @@ -16,6 +18,8 @@ const envelope = JSON.parse(readFileSync(new URL( schema_version: string; agent_status_counts: Record; user_status_counts: Record; + agent_status_order: string[]; + user_status_order: string[]; current_lease_count: number; retired_lease_count: number; standing_user_decision_count: number; @@ -24,6 +28,7 @@ const envelope = JSON.parse(readFileSync(new URL( completion_target_index: number; supersede_target_index: number; semantic_cases: Record>; + presentation_cases: Record>; }; export const PRODUCTION_SCALE_FIXTURE_SCHEMA = @@ -50,11 +55,26 @@ export interface ProductionScaleCoordinationFixture { readonly expected_user_archive_count: number; readonly expected_standing_user_decision_count: number; readonly semantic_cases: Readonly>>; + readonly presentation_cases: Readonly>>; } -function statusSeries(counts: Record): string[] { - return Object.entries(counts).flatMap(([status, count]) => - Array.from({length: count}, () => status)); +function statusSeries( + counts: Record, + order: readonly string[], + role: string, +): string[] { + const keys = Object.keys(counts).sort(); + const orderedKeys = [...order].sort(); + if (keys.length !== orderedKeys.length || keys.some((key, index) => key !== orderedKeys[index])) { + throw new Error(`${role} production fixture status order does not cover its counts`); + } + return order.flatMap(status => { + const count = counts[status]; + if (!Number.isSafeInteger(count) || count < 0) { + throw new Error(`${role} production fixture count is not a non-negative safe integer`); + } + return Array.from({length: count}, () => status); + }); } function todoId(role: "agent" | "user", index: number): string { @@ -69,8 +89,9 @@ function todoRecords( goalId: string, role: "agent" | "user", counts: Record, + order: readonly string[], ): Record[] { - return statusSeries(counts).map((status, index) => { + return statusSeries(counts, order, role).map((status, index) => { const done = status === "done" || status === "deferred"; const record: Record = { schema_version: TODO_ITEM_SCHEMA, @@ -138,12 +159,17 @@ function todoRecords( export function productionScaleCoordinationFixture( goalId: string, + schema: AuthorityProjectionSchema = "legacy", ): ProductionScaleCoordinationFixture { if (envelope.schema_version !== PRODUCTION_SCALE_FIXTURE_SCHEMA) { throw new Error("production-scale fixture envelope schema mismatch"); } - const agents = todoRecords(goalId, "agent", envelope.agent_status_counts); - const users = todoRecords(goalId, "user", envelope.user_status_counts); + const agents = todoRecords( + goalId, "agent", envelope.agent_status_counts, envelope.agent_status_order, + ); + const users = todoRecords( + goalId, "user", envelope.user_status_counts, envelope.user_status_order, + ); const archiveDependent = [...agents].reverse().find(item => item.status === "open")!; archiveDependent.task_class = "advancement_task"; archiveDependent.resume_when = `todo_done:${todoId("agent", 3)}`; @@ -158,7 +184,7 @@ export function productionScaleCoordinationFixture( supersedeTodo.task_class = "advancement_task"; supersedeTodo.claimed_by = "agent-b"; const todos = [...agents, ...users] - .sort((left, right) => String(left.todo_id).localeCompare(String(right.todo_id))); + .sort((left, right) => authorityUnicodeCompare(String(left.todo_id), String(right.todo_id))); const leasedIds = [ String(completionTodo.todo_id), String(supersedeTodo.todo_id), @@ -179,26 +205,26 @@ export function productionScaleCoordinationFixture( updated_at: observedAt(index), expires_at: index < 2 ? "2027-01-01T00:00:00Z" : observedAt(index + 1), status: index < 2 ? "active" : "released", - })).sort((left, right) => left.todo_id.localeCompare(right.todo_id)); + })).sort((left, right) => authorityUnicodeCompare(left.todo_id, right.todo_id)); const completionLease = leases.find((lease) => lease.todo_id === completionTodo.todo_id)!; const supersedeLease = leases.find((lease) => lease.todo_id === supersedeTodo.todo_id)!; - const initialAgentDone = envelope.agent_status_counts.done ?? 0; + const legacyProjection = authorityProjectionFixture( + goalId, + todos as Record[], + leases as Record[], + "legacy", + {source_authority: "synthetic_production_scale_fixture", handoff_mode: "hard_lease"}, + ); + const expectedAgentDone = agents.filter(todo => todo.status === "done").length; + const expectedUserDone = users.filter(todo => todo.status === "done").length; + const expectedStanding = users.filter(todo => + todo.task_class === "user_gate" && todo.decision_outcome === "approve" && + todo.global_gate === true && todo.goal_bound === true, + ).length; return { - projection: { - goal_id: goalId, - source_authority: "synthetic_production_scale_fixture", - handoff_mode: "hard_lease", - todos, - leases, - todo_read_model: { - schema_version: TODO_CANONICAL_READ_RECORD_SCHEMA, - todo_count: todos.length, - records_sha256: createHash("sha256") - .update(canonicalAuthorityBytes(todos)) - .digest("hex"), - contract_fields: [...TODO_CANONICAL_READ_RECORD_FIELDS], - }, - }, + projection: schema === "legacy" + ? legacyProjection + : projectionFixtureAsSchema(legacyProjection, schema), registered_agents: ["agent-a", "agent-b"], completion_todo_id: String(completionTodo.todo_id), supersede_todo_id: String(supersedeTodo.todo_id), @@ -208,10 +234,11 @@ export function productionScaleCoordinationFixture( supersede_lease_expected_version: supersedeLease.version, expected_initial_todo_count: todos.length, expected_current_lease_count: leases.length, - expected_agent_archive_count_after_terminals: initialAgentDone + 2 - 5, - expected_user_archive_count: (envelope.user_status_counts.done ?? 0) - 5, - expected_standing_user_decision_count: envelope.standing_user_decision_count, + expected_agent_archive_count_after_terminals: expectedAgentDone + 2 - 5, + expected_user_archive_count: expectedUserDone - 5, + expected_standing_user_decision_count: expectedStanding, semantic_cases: envelope.semantic_cases, + presentation_cases: envelope.presentation_cases, }; } diff --git a/tests/control_plane_ts/todo_presentation.test.ts b/tests/control_plane_ts/todo_presentation.test.ts new file mode 100644 index 0000000000..f7ff4135c9 --- /dev/null +++ b/tests/control_plane_ts/todo_presentation.test.ts @@ -0,0 +1,131 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; +import { + TODO_CANONICAL_READ_RECORD_SCHEMA, + TODO_DOMAIN_ITEM_SCHEMA, + TODO_DOMAIN_READ_RECORD_SCHEMA, + TODO_ITEM_SCHEMA, +} from "../../loopx/control_plane/coordination/coordination_state_contract.ts"; +import { + canonicalTodoRecord, + compareTodoPresentation, + materializeTodoRecordForSchema, + TODO_PRESENTATION_METADATA_SCHEMA, + todoPresentationMetadata, +} from "../../loopx/control_plane/coordination/todo_presentation.ts"; +import {productionScaleCoordinationFixture} from "./production_scale_coordination_fixture.ts"; + +function domainTodo(overrides: JsonObject = {}): JsonObject { + return { + schema_version: TODO_DOMAIN_ITEM_SCHEMA, + todo_id: "todo-a", + role: "agent", + status: "open", + done: false, + text: "Canonical display contract", + archive_state: "active", + ...overrides, + }; +} + +test("presentation metadata treats source location as display input", () => { + const legacy = { + ...domainTodo({schema_version: TODO_ITEM_SCHEMA}), + source_section: "Custom Agent Lane", + index: 7, + }; + assert.deepEqual(todoPresentationMetadata(legacy), { + schema_version: TODO_PRESENTATION_METADATA_SCHEMA, + todo_id: "todo-a", + display_section: "Custom Agent Lane", + display_order: 7, + order_source: "source_index", + }); +}); + +test("native records derive display section without persisting a fake index", () => { + const native = domainTodo({archive_state: "archive"}); + assert.deepEqual(todoPresentationMetadata(native), { + schema_version: "loopx_todo_presentation_metadata_v0", + todo_id: "todo-a", + display_section: "Completed Work Archive", + display_order: null, + order_source: "todo_id", + }); + assert.deepEqual( + materializeTodoRecordForSchema(native, TODO_DOMAIN_READ_RECORD_SCHEMA), + native, + ); +}); + +test("wire-shape materialization is an adapter, not a second domain construction", () => { + const native = domainTodo(); + const legacy = materializeTodoRecordForSchema( + native, + TODO_CANONICAL_READ_RECORD_SCHEMA, + ); + assert.equal(legacy.schema_version, TODO_ITEM_SCHEMA); + assert.equal(legacy.source_section, "Agent Todo"); + assert.equal(legacy.index, undefined); + assert.doesNotThrow(() => canonicalTodoRecord(legacy)); +}); + +test("presentation ordering keeps source coordinates and native time fallback", () => { + const legacyFirst = { + ...domainTodo({schema_version: TODO_ITEM_SCHEMA, todo_id: "todo-first"}), + source_section: "Agent Todo", + index: 1, + completed_at: "2026-09-10T10:00:00Z", + }; + const legacySecond = { + ...domainTodo({schema_version: TODO_ITEM_SCHEMA, todo_id: "todo-second"}), + source_section: "Agent Todo", + index: 2, + completed_at: "2026-09-10T09:00:00Z", + }; + assert.equal(compareTodoPresentation(legacyFirst, legacySecond) < 0, true); + + const nativeFirst = domainTodo({todo_id: "todo-first", updated_at: "2026-09-10T10:00:00Z"}); + const nativeSecond = domainTodo({todo_id: "todo-second", updated_at: "2026-09-10T09:00:00Z"}); + assert.equal(compareTodoPresentation(nativeFirst, nativeSecond) > 0, true); +}); + +test("compact compatibility rows may encode omitted schema fields as null", () => { + const compact = { + schema_version: null, + todo_id: "todo-compact", + role: "user", + status: "done", + archive_state: "active", + source_section: null, + index: null, + }; + assert.deepEqual(todoPresentationMetadata(compact), { + schema_version: TODO_PRESENTATION_METADATA_SCHEMA, + todo_id: "todo-compact", + display_section: "User Todo", + display_order: null, + order_source: "todo_id", + }); +}); + +test("production fixture carries one display contract for both wire shapes", () => { + const fixture = productionScaleCoordinationFixture("fixture-goal"); + const cases = fixture.presentation_cases; + assert.deepEqual(todoPresentationMetadata(cases.legacy_display), { + schema_version: TODO_PRESENTATION_METADATA_SCHEMA, + todo_id: "todo_fixture_display_legacy", + display_section: "Agent Todo", + display_order: 7, + order_source: "source_index", + }); + assert.deepEqual(todoPresentationMetadata(cases.native_display), { + schema_version: TODO_PRESENTATION_METADATA_SCHEMA, + todo_id: "todo_fixture_display_native", + display_section: "Completed Work Archive", + display_order: null, + order_source: "todo_id", + }); +}); diff --git a/tests/fixtures/control_plane/coordination_production_scale_v0.json b/tests/fixtures/control_plane/coordination_production_scale_v0.json index 5f0489bc45..67b6bc9656 100644 --- a/tests/fixtures/control_plane/coordination_production_scale_v0.json +++ b/tests/fixtures/control_plane/coordination_production_scale_v0.json @@ -6,11 +6,13 @@ "blocked": 32, "deferred": 16 }, + "agent_status_order": ["done", "open", "blocked", "deferred"], "user_status_counts": { "done": 192, "open": 8, "deferred": 8 }, + "user_status_order": ["done", "open", "deferred"], "current_lease_count": 64, "retired_lease_count": 160, "standing_user_decision_count": 4, @@ -59,5 +61,28 @@ "expires_at": "2024-01-01T00:00:00Z", "lease_epoch": 7 } + }, + "presentation_cases": { + "legacy_display": { + "schema_version": "todo_item_v0", + "todo_id": "todo_fixture_display_legacy", + "role": "agent", + "status": "open", + "done": false, + "text": "Legacy display address", + "archive_state": "active", + "source_section": "Agent Todo", + "index": 7 + }, + "native_display": { + "schema_version": "todo_domain_record_v0", + "todo_id": "todo_fixture_display_native", + "role": "agent", + "status": "open", + "done": false, + "text": "Native display address", + "archive_state": "archive", + "updated_at": "2025-01-02T00:00:00Z" + } } }