diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 106747840..b7332f4b2 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -213,9 +213,8 @@ jobs: needs: changes if: needs.changes.outputs.core_tests == 'true' runs-on: ubuntu-latest - # The full public-minimum conformance suite normally takes about 9-10 - # minutes on hosted runners. Keep enough headroom for setup and cleanup - # without dropping compatibility cases or weakening their deadlines. + # The full public-minimum suite now runs beyond ten minutes on hosted runners. + # Keep a bounded margin above the measured workload without weakening coverage. timeout-minutes: 15 steps: - uses: actions/checkout@v7 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 b94de05b4..c1988411c 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md @@ -1813,7 +1813,13 @@ It derives the compact projection from the current canonical Todo and task-lease views, reports only counts and digests, and requires `--execute` before invoking bootstrap or promotion. `promote` is effect-free without `--execute`; its preview returns the exact qualified revision, projection -digest, writer-fence identity, and rollback identity. Apply holds the shared +digest, qualification policy, canonical promotion-plan digest, writer-fence +identity, and rollback identity. The plan digest binds the Goal, operation, +selected canonical provider, exact shadow revision/projection, minimum operation +count, and normalized +required event kinds; the durable fence, event, and receipt carry the same +digest, so only the exact reviewed plan can recover a fence-before-canonical +interruption. Apply holds the shared maintenance and legacy source locks while it revalidates the source snapshot, qualifies the exact shadow lineage, engages the durable writer fence, commits the canonical head, and reads back the promotion receipt. v0 rejects a Goal 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 6b9634c89..820569e3b 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 @@ -1448,7 +1448,11 @@ loopx coordination-shadow rollback --goal-id \ 它从当前 canonical Todo 与 task-lease view 派生紧凑 projection,只报告计数与摘要, 并要求 `--execute` 才调用 bootstrap 或 promotion。`promote` 未带 `--execute` 时零写入; preview 返回精确的 qualified revision、projection digest、writer-fence identity 和 -rollback identity。apply 会在同一段 maintenance 与 legacy source 锁生命周期内重新 +rollback identity,并返回 canonical promotion-plan digest 及资格策略。该 digest 会绑定 +Goal、operation、选定 canonical provider、精确 shadow revision/projection、最小 +operation 数与规范化后的必需 +event kind;持久 fence、event 与 receipt 都携带同一 digest,因此 fence 已落盘而 canonical +尚未提交的中断只能由完全相同的受评审 plan 恢复。apply 会在同一段 maintenance 与 legacy source 锁生命周期内重新 验证 source snapshot、资格化精确 shadow lineage、engage 持久 writer fence、提交 canonical head,并读回 promotion receipt。v0 会拒绝尚未资格化为 `hard_lease` 的 Goal, 且绝不会把 handoff mode 变化藏在 promotion 副作用中。写入成功后会立即通过 typed parity inspection diff --git a/loopx/control_plane/coordination/legacy_writer_fence.ts b/loopx/control_plane/coordination/legacy_writer_fence.ts index 73e05b49a..124fc74f3 100644 --- a/loopx/control_plane/coordination/legacy_writer_fence.ts +++ b/loopx/control_plane/coordination/legacy_writer_fence.ts @@ -107,6 +107,12 @@ export function decodeLegacyCoordinationWriterFence(value: unknown): JsonObject fence.schema_version !== LEGACY_COORDINATION_WRITER_FENCE_SCHEMA || fence.state !== "engaged" ) throw new Error("legacy coordination writer fence must be engaged"); + const promotionPlanSha256 = fence.promotion_plan_sha256 === undefined + ? null + : requireAuthorityStoreId( + fence.promotion_plan_sha256, + "writer fence promotion plan sha256", + ); return canonicalAuthorityObject({ schema_version: LEGACY_COORDINATION_WRITER_FENCE_SCHEMA, state: "engaged", @@ -124,6 +130,9 @@ export function decodeLegacyCoordinationWriterFence(value: unknown): JsonObject fence.expected_shadow_provider_revision, "writer fence expected shadow provider revision", ), + ...(promotionPlanSha256 === null ? {} : { + promotion_plan_sha256: promotionPlanSha256, + }), }, "legacy coordination writer fence"); } diff --git a/loopx/control_plane/coordination/local_authority_runtime.ts b/loopx/control_plane/coordination/local_authority_runtime.ts index d76d97b18..3e2883569 100644 --- a/loopx/control_plane/coordination/local_authority_runtime.ts +++ b/loopx/control_plane/coordination/local_authority_runtime.ts @@ -33,6 +33,7 @@ import { } from "./coordination_projection.ts"; import { authorityStoreSourceAuthority, type AuthorityStore, type AuthorityStoreReceiptResult } from "./authority_store.ts"; import { + authorityUnicodeCompare, canonicalAuthorityBytes, canonicalAuthorityObject, canonicalAuthoritySha256, @@ -199,6 +200,15 @@ export async function reviewLocalCoordinationAuthorityPromotion( "qualified shadow provider revision", ); const projectionSha256 = canonicalAuthoritySha256(head); + const promotionPlanSha256 = localCoordinationPromotionPlanSha256({ + goal_id: input.goal_id, + operation_id: operationId, + canonical_authority: canonicalAuthority, + expected_shadow_provider_revision: providerRevision, + expected_shadow_projection_sha256: projectionSha256, + minimum_operations: minimumOperations, + required_event_kinds: requiredEventKinds, + }); const fence = canonicalAuthorityObject({ schema_version: LEGACY_COORDINATION_WRITER_FENCE_SCHEMA, state: "engaged", @@ -207,11 +217,13 @@ export async function reviewLocalCoordinationAuthorityPromotion( source_version: `shadow:${providerRevision}`, source_projection_sha256: projectionSha256, expected_shadow_provider_revision: providerRevision, + promotion_plan_sha256: promotionPlanSha256, }, "reviewed legacy writer fence"); const request: LocalCoordinationPromotionRequest = { runtime_root: input.runtime_root, goal_id: input.goal_id, operation_id: operationId, + canonical_authority: canonicalAuthority, expected_shadow_provider_revision: providerRevision, expected_shadow_projection_sha256: projectionSha256, minimum_operations: minimumOperations, @@ -276,9 +288,12 @@ export async function reviewLocalCoordinationAuthorityPromotion( } const plan = { operation_id: operationId, + promotion_plan_sha256: promotionPlanSha256, canonical_authority: canonicalAuthority, expected_shadow_provider_revision: providerRevision, expected_shadow_projection_sha256: projectionSha256, + minimum_operations: minimumOperations, + required_event_kinds: [...requiredEventKinds].sort(authorityUnicodeCompare), writer_fence: fence, rollback_identity: { provider: "file_v0", @@ -528,6 +543,7 @@ interface LocalCoordinationPromotionRequest { runtime_root: string; goal_id: string; operation_id: string; + canonical_authority: string; expected_shadow_provider_revision: string; expected_shadow_projection_sha256: string; minimum_operations: number; @@ -535,6 +551,47 @@ interface LocalCoordinationPromotionRequest { writer_fence: JsonObject; } +export interface LocalCoordinationPromotionPlanInput { + goal_id: string; + operation_id: string; + canonical_authority: string; + expected_shadow_provider_revision: string; + expected_shadow_projection_sha256: string; + minimum_operations: number; + required_event_kinds: string[]; +} + +export function localCoordinationPromotionPlanSha256( + value: LocalCoordinationPromotionPlanInput, +): string { + const plan = canonicalAuthorityObject({ + schema_version: "loopx_local_coordination_promotion_plan_v0", + goal_id: requireAuthorityStoreId(value.goal_id, "goal id"), + operation_id: requireAuthorityStoreId(value.operation_id, "operation id"), + canonical_authority: requireAuthorityStoreId( + value.canonical_authority, + "canonical authority", + ), + expected_shadow_provider_revision: requireAuthorityStoreId( + value.expected_shadow_provider_revision, + "expected shadow provider revision", + ), + expected_shadow_projection_sha256: requireAuthorityStoreId( + value.expected_shadow_projection_sha256, + "expected shadow projection sha256", + ), + minimum_operations: requiredPositiveSafeInteger( + value.minimum_operations, + "minimum_operations", + ), + required_event_kinds: requiredUniqueStrings( + value.required_event_kinds, + "required_event_kinds", + ).sort(authorityUnicodeCompare), + }, "local coordination promotion plan"); + return canonicalAuthoritySha256(plan); +} + function decodePromotionRequest(value: unknown): LocalCoordinationPromotionRequest { const input = requireJsonObject(value, "local coordination promotion request"); if (input.schema_version !== LOCAL_COORDINATION_PROMOTION_REQUEST_SCHEMA) { @@ -545,6 +602,10 @@ function decodePromotionRequest(value: unknown): LocalCoordinationPromotionReque runtime_root: runtimeRoot(input.runtime_root), goal_id: requireAuthorityStoreId(input.goal_id, "goal id"), operation_id: requireAuthorityStoreId(input.operation_id, "operation id"), + canonical_authority: requireAuthorityStoreId( + input.canonical_authority, + "canonical authority", + ), expected_shadow_provider_revision: requireAuthorityStoreId( input.expected_shadow_provider_revision, "expected shadow provider revision", @@ -574,6 +635,7 @@ function promotionIdentity(request: LocalCoordinationPromotionRequest): JsonObje source_projection_sha256: request.expected_shadow_projection_sha256, writer_fence_id: request.writer_fence.fence_id, source_version: request.writer_fence.source_version, + promotion_plan_sha256: localCoordinationPromotionPlanSha256(request), }, "local coordination promotion identity"); } @@ -634,6 +696,7 @@ function promotionResult( source_projection_sha256: request.expected_shadow_projection_sha256, writer_fence_id: request.writer_fence.fence_id, source_version: request.writer_fence.source_version, + promotion_plan_sha256: localCoordinationPromotionPlanSha256(request), canonical_authority: canonicalAuthority, legacy_writer_fenced: true, legacy_fallback_used: false, @@ -687,7 +750,6 @@ export async function promoteLocalCoordinationAuthority( legacy_fallback_used: false, }; } - // Provider opening can fail before durable fence readback. Report only // evidence this invocation actually verified, including in the outer catch. let writerFenceVerified = false; @@ -701,6 +763,26 @@ export async function promoteLocalCoordinationAuthority( request.goal_id, ) ?? await openRuntimeStore(request.runtime_root, request.goal_id, dependencies); const canonicalAuthority = sourceAuthorityFor(canonical); + if (request.canonical_authority !== canonicalAuthority) return { + schema_version: LOCAL_COORDINATION_PROMOTION_RESULT_SCHEMA, + status: "failed", + reason_code: "local_authority_promotion_provider_mismatch", + reason: "promotion request is not bound to the selected canonical authority provider", + expected_canonical_authority: request.canonical_authority, + observed_canonical_authority: canonicalAuthority, + legacy_writer_fenced: false, + legacy_fallback_used: false, + }; + const promotionPlanSha256 = localCoordinationPromotionPlanSha256(request); + if (request.writer_fence.promotion_plan_sha256 !== promotionPlanSha256) return { + schema_version: LOCAL_COORDINATION_PROMOTION_RESULT_SCHEMA, + status: "failed", + reason_code: "local_authority_writer_fence_plan_mismatch", + reason: "writer fence is not bound to the complete reviewed promotion plan", + promotion_plan_sha256: promotionPlanSha256, + legacy_writer_fenced: false, + legacy_fallback_used: false, + }; const persistedFence = await loadLegacyCoordinationWriterFence( request.runtime_root, request.goal_id, diff --git a/tests/control_plane/test_sqlite_authority_cli.py b/tests/control_plane/test_sqlite_authority_cli.py index 447a30be6..70dda4862 100644 --- a/tests/control_plane/test_sqlite_authority_cli.py +++ b/tests/control_plane/test_sqlite_authority_cli.py @@ -121,6 +121,7 @@ def durable_bytes(): result = effect_runtime_result("coordination.local_authority.promote", { "schema_version": "loopx_local_coordination_promotion_request_v0", "runtime_root": str(runtime), "goal_id": "sqlite-goal", "operation_id": "promotion-negative", + "canonical_authority": "sqlite_v0", "expected_shadow_provider_revision": "file:synthetic:1", "expected_shadow_projection_sha256": "a" * 64, "minimum_operations": 1, "required_event_kinds": ["todo_claim"], "writer_fence": { diff --git a/tests/control_plane_ts/local_authority_provider.test.ts b/tests/control_plane_ts/local_authority_provider.test.ts index 05db051ee..271b793fd 100644 --- a/tests/control_plane_ts/local_authority_provider.test.ts +++ b/tests/control_plane_ts/local_authority_provider.test.ts @@ -347,14 +347,25 @@ for (const provider of ["file", "sqlite"] as const) { const canonical = await openLocalAuthorityStore(directory, "goal-a"); const shadow = await qualifiedShadow(directory); const shadowStore = new FileAuthorityStore(join(directory, "authority-shadow", "file-v0"), "goal-a"); - const request = promotionRequest(directory, shadow.projection, shadow.providerRevision); + const canonicalAuthority = provider === "sqlite" ? "sqlite_v0" : "file_v0"; + const request = promotionRequest( + directory, + shadow.projection, + shadow.providerRevision, + canonicalAuthority, + ); if (phase === "shadow_invalid") { // A valid store row can still contain an invalid domain projection. const projection = {...shadow.projection, goal_id: "different-goal"}; const committed = await shadowStore.commitAuthority({operation_id: "invalid-domain", expected_provider_revision: shadow.providerRevision, next_projection: projection, receipts: [], events: []}); assert.equal(committed.status, "applied"); if (committed.status !== "applied") return; - Object.assign(request, promotionRequest(directory, projection, committed.provider_revision)); + Object.assign(request, promotionRequest( + directory, + projection, + committed.provider_revision, + canonicalAuthority, + )); } if (phase !== "fence_missing") await engageFence(request); const fencePath = legacyCoordinationWriterFencePath(directory, "goal-a"); @@ -370,7 +381,9 @@ for (const provider of ["file", "sqlite"] as const) { operation_id: request.operation_id, goal_id: request.goal_id, source_shadow_provider_revision: request.expected_shadow_provider_revision, source_projection_sha256: request.expected_shadow_projection_sha256, - writer_fence_id: request.writer_fence.fence_id, source_version: request.writer_fence.source_version}; + writer_fence_id: request.writer_fence.fence_id, + source_version: request.writer_fence.source_version, + promotion_plan_sha256: request.writer_fence.promotion_plan_sha256}; const seeded = await canonical.commitAuthority({operation_id: phase === "receipt_missing" ? "other-operation" : request.operation_id, expected_provider_revision: null, next_projection: phase === "lineage_mismatch" ? {...shadow.projection, extra: "different snapshot"} : shadow.projection, diff --git a/tests/control_plane_ts/local_authority_runtime.test.ts b/tests/control_plane_ts/local_authority_runtime.test.ts index 903644e13..179bf4355 100644 --- a/tests/control_plane_ts/local_authority_runtime.test.ts +++ b/tests/control_plane_ts/local_authority_runtime.test.ts @@ -7,7 +7,10 @@ import test from "node:test"; import type { JsonObject } from "../../loopx/control_plane/effect_program.ts"; import { FileAuthorityStore } from "../../loopx/control_plane/coordination/file_authority_store.ts"; -import type { AuthorityStoreCommit } from "../../loopx/control_plane/coordination/authority_store.ts"; +import type { + AuthorityStore, + AuthorityStoreCommit, +} from "../../loopx/control_plane/coordination/authority_store.ts"; import { AuthorityStoreProtocolError, canonicalAuthorityBytes, @@ -225,12 +228,22 @@ test("local promotion fences shadow revision, digest, and writer-fence identity" "local_authority_writer_fence_projection_mismatch", ); + const mismatchedProvider = await promoteLocalCoordinationAuthority({ + ...request, + canonical_authority: "sqlite_v0", + }); + assert.equal(mismatchedProvider.status, "failed"); + assert.equal( + mismatchedProvider.reason_code, + "local_authority_promotion_provider_mismatch", + ); + const unqualified = await promoteLocalCoordinationAuthority({ ...request, minimum_operations: 2, }); assert.equal(unqualified.status, "failed"); - assert.equal(unqualified.reason_code, "local_authority_shadow_not_qualified"); + assert.equal(unqualified.reason_code, "local_authority_writer_fence_plan_mismatch"); const canonical = new FileAuthorityStore(join(root, "authority", "file-v0"), "goal-a"); assert.equal((await canonical.loadAuthority()).status, "missing"); }); @@ -281,7 +294,7 @@ test("reviewed promotion previews without effects and atomically applies the who test("reviewed promotion resumes the exact request after a fence-to-canonical interruption", async () => { const root = await mkdtemp(join(tmpdir(), "loopx-reviewed-promotion-recovery-")); - const shadow = await qualifiedShadow(root, "hard_lease"); + const shadow = await qualifiedShadow(root, "hard_lease", 2); const sourceProjection = { ...shadow.projection }; delete sourceProjection.capture_lineage_id; delete sourceProjection.capture_profile; @@ -298,7 +311,7 @@ test("reviewed promotion resumes the exact request after a fence-to-canonical in ...source, schema_version: LOCAL_COORDINATION_PROMOTION_REVIEW_REQUEST_SCHEMA, operation_id: "promote:goal-a:recoverable", - minimum_operations: 1, + minimum_operations: 2, required_event_kinds: ["todo_claim"], execute: true, }; @@ -331,6 +344,36 @@ test("reviewed promotion resumes the exact request after a fence-to-canonical in assert.equal(changed.legacy_writer_fenced, true); assert.equal((await canonical.loadAuthority()).status, "missing"); + const providerChangedStore: AuthorityStore = { + providerKind: "sqlite", + storeIdentity: () => canonical.storeIdentity(), + loadAuthority: () => canonical.loadAuthority(), + commitAuthority: (commit) => canonical.commitAuthority(commit), + readReceipt: (operationId) => canonical.readReceipt(operationId), + scanCommitted: (afterCursor, limit) => canonical.scanCommitted(afterCursor, limit), + }; + const providerChanged = await reviewLocalCoordinationAuthorityPromotion(request, { + createCanonicalStore: () => providerChangedStore, + }); + assert.equal(providerChanged.status, "failed", JSON.stringify(providerChanged)); + assert.equal(providerChanged.reason_code, "local_authority_writer_fence_conflict"); + assert.equal(providerChanged.legacy_writer_fenced, true); + assert.equal((await canonical.loadAuthority()).status, "missing"); + + for (const changedPolicy of [ + {...request, minimum_operations: 1}, + {...request, required_event_kinds: []}, + ]) { + const rejected = await reviewLocalCoordinationAuthorityPromotion( + changedPolicy, + dependencies, + ); + assert.equal(rejected.status, "failed", JSON.stringify(rejected)); + assert.equal(rejected.reason_code, "local_authority_writer_fence_conflict"); + assert.equal(rejected.legacy_writer_fenced, true); + assert.equal((await canonical.loadAuthority()).status, "missing"); + } + const recovered = await reviewLocalCoordinationAuthorityPromotion(request, dependencies); assert.equal(recovered.status, "recovered", JSON.stringify(recovered)); assert.equal(recovered.legacy_writer_fenced, true); diff --git a/tests/control_plane_ts/local_promotion_fixture.ts b/tests/control_plane_ts/local_promotion_fixture.ts index 10216e3bd..eee48b391 100644 --- a/tests/control_plane_ts/local_promotion_fixture.ts +++ b/tests/control_plane_ts/local_promotion_fixture.ts @@ -3,7 +3,10 @@ import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { FileAuthorityStore } from "../../loopx/control_plane/coordination/file_authority_store.ts"; import { canonicalAuthoritySha256 as sha256 } from "../../loopx/control_plane/coordination/authority_store_codec.ts"; -import { LOCAL_COORDINATION_PROMOTION_REQUEST_SCHEMA } from "../../loopx/control_plane/coordination/local_authority_runtime.ts"; +import { + LOCAL_COORDINATION_PROMOTION_REQUEST_SCHEMA, + localCoordinationPromotionPlanSha256, +} from "../../loopx/control_plane/coordination/local_authority_runtime.ts"; import { engageLegacyCoordinationWriterFence, LEGACY_COORDINATION_WRITER_FENCE_ENGAGE_REQUEST_SCHEMA, LEGACY_COORDINATION_WRITER_FENCE_SCHEMA } from "../../loopx/control_plane/coordination/legacy_writer_fence.ts"; import { bootstrapCoordinationRuntimeShadow, COORDINATION_RUNTIME_SHADOW_BOOTSTRAP_REQUEST_SCHEMA } from "../../loopx/control_plane/coordination/runtime_shadow.ts"; @@ -16,7 +19,7 @@ function todoRecord(overrides: Record = {}) { } // Build a mirrored shadow fixture; this does not authorize promotion. -export async function qualifiedShadow(root: string, handoffMode = "soft_claim") { +export async function qualifiedShadow(root: string, handoffMode = "soft_claim", operationCount = 1) { const baseline = fileProjection([todoRecord()], [], handoffMode); const statePath = join(root, "ACTIVE_GOAL_STATE.md"); await writeFile(statePath, "---\ngoal_id: goal-a\nhandoff_mode: soft_claim\n---\n\n## Agent Todo\n\n"); @@ -28,11 +31,20 @@ export async function qualifiedShadow(root: string, handoffMode = "soft_claim") operation_id: "bootstrap:goal-a:state-0", source_version: "state:0", }); assert.equal(bootstrapped.status, "applied", JSON.stringify(bootstrapped)); - const entry = await pendingEntry(f, 1, {handoff_mode: handoffMode, todos: [todoRecord({claimed_by: "agent-a"})]}, - {writeClass: "todo_claim"}); - const mirrored = await commitLocalAuthorityShadowEntry(entry); - assert.equal(mirrored.outcome, "delivered", JSON.stringify(mirrored)); - await settleFiles(f, entry, mirrored); + for (let sequence = 1; sequence <= operationCount; sequence += 1) { + const entry = await pendingEntry( + f, + sequence, + {handoff_mode: handoffMode, todos: [todoRecord({ + claimed_by: "agent-a", + ...(sequence === 1 ? {} : {text: `Qualify canonical Todo semantics ${sequence}`}), + })]}, + {writeClass: sequence === 1 ? "todo_claim" : "todo_update"}, + ); + const mirrored = await commitLocalAuthorityShadowEntry(entry); + assert.equal(mirrored.outcome, "delivered", JSON.stringify(mirrored)); + await settleFiles(f, entry, mirrored); + } const loaded = await store.loadAuthority(); assert.equal(loaded.status, "loaded"); if (loaded.status !== "loaded") throw new Error("fixture head missing"); @@ -43,17 +55,22 @@ export function promotionRequest( root: string, projection: Record, providerRevision: string, + canonicalAuthority = "file_v0", ) { const digest = sha256(projection); - return { - schema_version: LOCAL_COORDINATION_PROMOTION_REQUEST_SCHEMA, - runtime_root: root, + const plan = { goal_id: "goal-a", operation_id: "promote:goal-a:state-1", + canonical_authority: canonicalAuthority, expected_shadow_provider_revision: providerRevision, expected_shadow_projection_sha256: digest, minimum_operations: 1, required_event_kinds: ["todo_claim"], + }; + return { + schema_version: LOCAL_COORDINATION_PROMOTION_REQUEST_SCHEMA, + runtime_root: root, + ...plan, writer_fence: { schema_version: LEGACY_COORDINATION_WRITER_FENCE_SCHEMA, state: "engaged", @@ -62,6 +79,7 @@ export function promotionRequest( source_version: "state:1", source_projection_sha256: digest, expected_shadow_provider_revision: providerRevision, + promotion_plan_sha256: localCoordinationPromotionPlanSha256(plan), }, }; }