diff --git a/contracts/portable-workspace/v1/receiver-checkpoint-v2.md b/contracts/portable-workspace/v1/receiver-checkpoint-v2.md index 7627d3f68..d9cd45aea 100644 --- a/contracts/portable-workspace/v1/receiver-checkpoint-v2.md +++ b/contracts/portable-workspace/v1/receiver-checkpoint-v2.md @@ -56,6 +56,15 @@ only separate current-policy revalidation returns the private controlled-source readback. Durability remains Unknown, with ACK, host effects and provider/native qualification outside this source boundary. +`CreateControlledFailureTest` selects one fixed test cut in this same current +source store: before writing, before file flush, before replacement, after +replacement before fresh readback, or before cold reopen. A failed Stage returns +commit uncertainty, without a source readback or ACK. Earlier cuts retain the +prior record; later cuts can leave the exact proposed record. A later explicit +fresh read and current signature/policy verification can authenticate those +persisted source bytes, while directory durability remains Unknown. The +controls operate on current `/4` source records. + The SDK-generated `packages.lock.json` files retain NuGet package-content hashes. For signed packages that identity can differ from a raw archive SHA512: NuGet's [immutable package reader source](https://github.com/NuGet/NuGet.Client/blob/d3a633a04da7cd83900498334ec5e7650a52fa86/src/NuGet.Core/NuGet.Packaging/PackageArchiveReader.cs) diff --git a/docs/atlas/catalog.json b/docs/atlas/catalog.json index 27fee0056..b83ce2333 100644 --- a/docs/atlas/catalog.json +++ b/docs/atlas/catalog.json @@ -1 +1 @@ -{"records":["1306897f80e4dad31f35a9b17892817682d6d8197af806ac280359d108df41ce","1599c759337efc6aa9e4fbcbffe47ae443ae0567ed400f4a27fa1961ee706a79","237009a22183351ec6c833d13c97c93cb911406f6cd74e5e27020afef0d5720b","2b3d5ce32c8e9deb719d33d8db3380e6268b423f3b1383fc5d5a48dc94977046","3d04ecbf2616fd50f96973bec42cab1bc72b448db6aaa37d85fe557854c2abd0","3d2ddd3aa00319dc22511a200368781cf06f63960047a4663dfd3945db7b0b58","4af487a8b7d5d7b7d2b6cd76abb2d7222c351639510cd2736d9fbd2fa0d1d0ec","5717792fd7c66d7067c36b14b140334db88cda7776c603abbfea2f79709414a4","5a2bb285b7c2ad9581dbac734e8d0608b64fe2f3a4fd9afb2eb5ac95d02ab1a8","5ccfe414a0111cc37f8675524cdf098142886aa64e5d45a365c7805d80e93e67","60a3dabd6d4116669b3759951e1a6cb1dd42a366391721e10055d0acfe62c7d1","61b223c20e3ee397c69a8ed5be013b22591ecb57af5c7b06c7e38f8b6195b3bd","6833bd4fca1aab717b0165c0a7a2740fa446326540a17a2c0575523261764052","6f69d840fa4a0913fead2214af53f1ca8b0c84aa6ca286f327bfd32dffbf2d75","7820e241bc943754b6f91ce1d1c480ead9bbb6001350b98954d2724efdd50a54","7da672e43fa1273c57144fd2b6e527ce1ccc80ec5c2d43676e60f39fd3168b8e","952998c76e62f135d632d13bd41d277b18a216a933dfd7ad059986b4910b04ad","95a50f852cd71d4a08c3faf69c8e1b18d36d1004bfa6ae9008b5237bbe2164b5","abf3845e007a5164fc4e269f4f090f9655319d037ff6088d25f342c16cbaf1ad","bc4b1d14d1a9605be34c27ab1fba043c41e70e38e3005eaf0783f1edd191cac5","c4d5b92d4a5e1086302ed630d0e95da3698ac702ba7f698716e85095fd074ec2","d355514e885473717786a787c94e97620253f7c510ebe4fcaa397e9911329812","d5d7760b4174add5159f7481035acf05cebd2f029e47d4f5428b6e4742e89153","f5f940119685e516c5ccdbe4f798569494d6e439944af87f1b6f9ca783b06f3f"],"schema":"fsgg.atlas/1"} +{"records":["1306897f80e4dad31f35a9b17892817682d6d8197af806ac280359d108df41ce","1599c759337efc6aa9e4fbcbffe47ae443ae0567ed400f4a27fa1961ee706a79","237009a22183351ec6c833d13c97c93cb911406f6cd74e5e27020afef0d5720b","2b3d5ce32c8e9deb719d33d8db3380e6268b423f3b1383fc5d5a48dc94977046","3d04ecbf2616fd50f96973bec42cab1bc72b448db6aaa37d85fe557854c2abd0","3d2ddd3aa00319dc22511a200368781cf06f63960047a4663dfd3945db7b0b58","4af487a8b7d5d7b7d2b6cd76abb2d7222c351639510cd2736d9fbd2fa0d1d0ec","4c801e226b64bbbefe8a5e040b3611e98b3c930f1b15ef361da4d5a4d9fcb540","5717792fd7c66d7067c36b14b140334db88cda7776c603abbfea2f79709414a4","5a2bb285b7c2ad9581dbac734e8d0608b64fe2f3a4fd9afb2eb5ac95d02ab1a8","5ccfe414a0111cc37f8675524cdf098142886aa64e5d45a365c7805d80e93e67","60a3dabd6d4116669b3759951e1a6cb1dd42a366391721e10055d0acfe62c7d1","61b223c20e3ee397c69a8ed5be013b22591ecb57af5c7b06c7e38f8b6195b3bd","6833bd4fca1aab717b0165c0a7a2740fa446326540a17a2c0575523261764052","6f69d840fa4a0913fead2214af53f1ca8b0c84aa6ca286f327bfd32dffbf2d75","7820e241bc943754b6f91ce1d1c480ead9bbb6001350b98954d2724efdd50a54","7da672e43fa1273c57144fd2b6e527ce1ccc80ec5c2d43676e60f39fd3168b8e","952998c76e62f135d632d13bd41d277b18a216a933dfd7ad059986b4910b04ad","95a50f852cd71d4a08c3faf69c8e1b18d36d1004bfa6ae9008b5237bbe2164b5","abf3845e007a5164fc4e269f4f090f9655319d037ff6088d25f342c16cbaf1ad","bc4b1d14d1a9605be34c27ab1fba043c41e70e38e3005eaf0783f1edd191cac5","c4d5b92d4a5e1086302ed630d0e95da3698ac702ba7f698716e85095fd074ec2","d355514e885473717786a787c94e97620253f7c510ebe4fcaa397e9911329812","d5d7760b4174add5159f7481035acf05cebd2f029e47d4f5428b6e4742e89153","f468ea204807c155dca4f8d63aeba2d45b22a6873b1708982088aa674108d280","f5f940119685e516c5ccdbe4f798569494d6e439944af87f1b6f9ca783b06f3f"],"schema":"fsgg.atlas/1"} diff --git a/docs/atlas/records/4c801e226b64bbbefe8a5e040b3611e98b3c930f1b15ef361da4d5a4d9fcb540.json b/docs/atlas/records/4c801e226b64bbbefe8a5e040b3611e98b3c930f1b15ef361da4d5a4d9fcb540.json new file mode 100644 index 000000000..78c2b4a77 --- /dev/null +++ b/docs/atlas/records/4c801e226b64bbbefe8a5e040b3611e98b3c930f1b15ef361da4d5a4d9fcb540.json @@ -0,0 +1 @@ +{"citations":[{"path":"src/FS.GG.Coordination.Orchestration.Execution/PortableWorkspaceExecutor.fs","repository":"FS-GG/FS.GG.Coordination","revision":"b1304e7a39182d765fb01ebdc490f9d9c5ab8a8b","sha256":"302c1d4d86cea8289549a0f499b88cee38ba3b0379129c984eb35d0a39a2de0b"},{"path":"tests/FS.GG.Coordination.Orchestration.Execution.Tests/PortableHostExecutorTests.fs","repository":"FS-GG/FS.GG.Coordination","revision":"b1304e7a39182d765fb01ebdc490f9d9c5ab8a8b","sha256":"96c2bc6b79246b1cd85431183c085bb8405d3c160991e084228ebcf94ed302b4"},{"path":"contracts/portable-workspace/v1/receiver-checkpoint-v2.md","repository":"FS-GG/FS.GG.Coordination","revision":"b1304e7a39182d765fb01ebdc490f9d9c5ab8a8b","sha256":"b1b2f9878658656058ffba1950c4c1d5f699c67b213908119a74a36e81fb327a"}],"conflictsWith":[],"id":"coordination/supplier-source-store-failure-boundary","kind":"boundary","recordedAt":"2026-10-16T15:25:02Z","repository":"FS-GG/FS.GG.Coordination","schema":"fsgg.atlas/1","status":"recorded","summary":"The current /4 inactive supplier source store exposes finite controlled TEST failure-point data through the existing writer, CAS lock and serializer. Cuts before writing, before explicit file flush, before replacement, after replacement before fresh readback and before cold reopen return commit uncertainty instead of a Stage source readback. Earlier cuts retain the pending record; later cuts may retain the exact proposed record, including original identity and full signed custody. The same genuine signed zero and selected journeys contain regression assertions for all five cuts through creation, genesis, unchanged, append and native-proof transitions. Each cut also asserts prepareAck and reserveHost refusal and zero runner run, cleanup and recovery calls. A later explicit fresh read plus current signature/policy verification can authenticate persisted controlled SOURCE bytes; that result neither proves directory durability nor supplies ACK/runner authority. Both the normal source-journal constructor and ordinary executor select no cut; only the explicit controlled-test factory selects one fixed failure point. Source citations bind the corrected complete byte-array equality and both constructor callers. This record describes source and regression assertions; current runtime qualification and production/native acceptance require their own evidence.","supersedes":["f468ea204807c155dca4f8d63aeba2d45b22a6873b1708982088aa674108d280"],"tags":["supplier","store","source","uncertainty","custody","failure"],"title":"Failed supplier Stage does not authenticate a commit"} diff --git a/docs/atlas/records/f468ea204807c155dca4f8d63aeba2d45b22a6873b1708982088aa674108d280.json b/docs/atlas/records/f468ea204807c155dca4f8d63aeba2d45b22a6873b1708982088aa674108d280.json new file mode 100644 index 000000000..bb1140327 --- /dev/null +++ b/docs/atlas/records/f468ea204807c155dca4f8d63aeba2d45b22a6873b1708982088aa674108d280.json @@ -0,0 +1 @@ +{"citations":[{"path":"src/FS.GG.Coordination.Orchestration.Execution/PortableWorkspaceExecutor.fs","repository":"FS-GG/FS.GG.Coordination","revision":"819d5e8b4da382092d20cb3c22029988b4f9a98c","sha256":"bbe9932485665b33199e47297b9d3e9ee9626994a03131d31ce937e4bd047062"},{"path":"tests/FS.GG.Coordination.Orchestration.Execution.Tests/PortableHostExecutorTests.fs","repository":"FS-GG/FS.GG.Coordination","revision":"819d5e8b4da382092d20cb3c22029988b4f9a98c","sha256":"d90c5c2f6af857713a08d06e765a94f15844a924c909251a04b78f9b8dce2342"},{"path":"contracts/portable-workspace/v1/receiver-checkpoint-v2.md","repository":"FS-GG/FS.GG.Coordination","revision":"819d5e8b4da382092d20cb3c22029988b4f9a98c","sha256":"b1b2f9878658656058ffba1950c4c1d5f699c67b213908119a74a36e81fb327a"}],"conflictsWith":[],"id":"coordination/supplier-source-store-failure-boundary","kind":"boundary","recordedAt":"2026-10-16T14:57:23Z","repository":"FS-GG/FS.GG.Coordination","schema":"fsgg.atlas/1","status":"recorded","summary":"The current /4 inactive supplier source store exposes finite controlled TEST failure-point data through the existing writer, CAS lock and serializer. Cuts before writing, before explicit file flush, before replacement, after replacement before fresh readback and before cold reopen return commit uncertainty instead of a Stage source readback. Earlier cuts retain the pending record; later cuts may retain the exact proposed record, including original identity and full signed custody. The same genuine signed zero and selected journeys contain regression assertions for all five cuts through creation, genesis, unchanged, append and native-proof transitions. Each cut also asserts prepareAck and reserveHost refusal and zero runner run, cleanup and recovery calls. A later explicit fresh read plus current signature/policy verification can authenticate persisted controlled SOURCE bytes; that result neither proves directory durability nor supplies ACK/runner authority. The normal source journal constructor selects no cut. This record describes prepared source and test assertions, not runtime qualification or production/native acceptance.","supersedes":[],"tags":["supplier","store","source","uncertainty","custody","failure"],"title":"Failed supplier Stage does not authenticate a commit"} diff --git a/src/FS.GG.Coordination.Orchestration.Execution/PortableWorkspaceExecutor.fs b/src/FS.GG.Coordination.Orchestration.Execution/PortableWorkspaceExecutor.fs index 65f5fdd1b..e910c9b89 100644 --- a/src/FS.GG.Coordination.Orchestration.Execution/PortableWorkspaceExecutor.fs +++ b/src/FS.GG.Coordination.Orchestration.Execution/PortableWorkspaceExecutor.fs @@ -945,7 +945,17 @@ module PortableWorkspaceExecutor = | _ -> false | _ -> false - type private DurableJournal(stateRoot: string) = + /// Finite controlled TEST cuts in the current supplier source store; no effect capability. + type ControlledSupplierStoreFailurePoint = + | BeforeSourceWrite + | BeforeSourceFileFlush + | BeforeSourceReplacement + | AfterSourceReplacementBeforeReadback + | BeforeSourceColdReopen + + type private DurableJournal(stateRoot: string, sourceFailure: ControlledSupplierStoreFailurePoint option) = + let failSourceAt point = + if sourceFailure = Some point then raise (IOException "controlled-supplier-store-failure") let journalRoot = Path.Combine(stateRoot, "journal-v1") let ensureRoot () = @@ -1013,6 +1023,7 @@ module PortableWorkspaceExecutor = let temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp" try + if prepared.IsSome then failSourceAt BeforeSourceWrite use stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None) @@ -1040,6 +1051,7 @@ module PortableWorkspaceExecutor = writeReceipt writer receipt | PortableHost _ | PortableSupplierAdmissionDraft _ -> invalidOp "portable-journal-state-refused" + if prepared.IsSome then failSourceAt BeforeSourceFileFlush writer.Flush() stream.Flush true if stream.Length > int64 PortableWorkspaceContract.maximumDocumentBytes * 4L then @@ -1048,6 +1060,7 @@ module PortableWorkspaceExecutor = | PortableSupplierAdmissionDraft _ when stream.Length > int64 PortableSupplierAdmission.MaximumSupplierBytes -> invalidOp "supplier-journal-size" | _ -> () + if prepared.IsSome then failSourceAt BeforeSourceReplacement File.Move(temporary, path, true) finally if File.Exists temporary then @@ -1133,6 +1146,7 @@ module PortableWorkspaceExecutor = let expectedBytes = if state.JournalVersion = SupplierJournalV4 then Some(supplierSourceBytes state) else None write path (PortableSupplierAdmissionDraft state) expectedBytes |> Option.iter (fun expected -> + failSourceAt AfterSourceReplacementBeforeReadback // New descriptor, bounded exact bytes, still under the same owner lock. use fresh = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read) if fresh.Length <> int64 expected.Length then invalidOp "supplier-source-readback-size" @@ -1140,6 +1154,7 @@ module PortableWorkspaceExecutor = fresh.ReadExactly(actual.AsSpan()) if fresh.ReadByte() <> -1 || actual <> expected then invalidOp "supplier-source-readback-mismatch" // Cold decoding invokes all structural checkpoint/reference checks again. + failSourceAt BeforeSourceColdReopen match read path with | Some(PortableSupplierAdmissionDraft decoded) when decoded = state -> () | _ -> invalidOp "supplier-source-cold-readback-mismatch") @@ -1193,8 +1208,12 @@ module PortableWorkspaceExecutor = /// Inactive source journal only: shares the existing key/lock, never a runner or transport. /// Every successful write is still durability-Unknown; no production ACK/handoff API exists. - type SupplierAdmissionJournal(stateRoot: string) = - let journal = DurableJournal(stateRoot) + type SupplierAdmissionJournal private (stateRoot: string, sourceFailure: ControlledSupplierStoreFailurePoint option) = + let journal = DurableJournal(stateRoot, sourceFailure) + new(stateRoot: string) = SupplierAdmissionJournal(stateRoot, None) + /// Test data selects one fixed current-source cut, never an arbitrary callback or alternate store. + static member CreateControlledFailureTest(stateRoot: string, point: ControlledSupplierStoreFailurePoint) = + SupplierAdmissionJournal(stateRoot, Some point) member _.Read(command: PortableWorkspaceCommand) : Result, string> = journal.Read(command.WorkspaceScope, command.IdempotencyId) |> Result.bind (function @@ -1292,7 +1311,7 @@ module PortableWorkspaceExecutor = let clock = defaultArg clock (fun () -> let now = DateTimeOffset.UtcNow DateTimeOffset(now.Ticks - now.Ticks % 10L, TimeSpan.Zero)) - let journal = DurableJournal policy.Runtime.StateRoot + let journal = DurableJournal(policy.Runtime.StateRoot, None) let prerequisites = defaultArg prerequisites Map.empty let evidenceCurrent () = try (defaultArg hostEvidenceCurrent (fun () -> true)) () diff --git a/tests/FS.GG.Coordination.Orchestration.Execution.Tests/PortableHostExecutorTests.fs b/tests/FS.GG.Coordination.Orchestration.Execution.Tests/PortableHostExecutorTests.fs index ef2a252fe..094481202 100644 --- a/tests/FS.GG.Coordination.Orchestration.Execution.Tests/PortableHostExecutorTests.fs +++ b/tests/FS.GG.Coordination.Orchestration.Execution.Tests/PortableHostExecutorTests.fs @@ -1419,6 +1419,43 @@ type PortableHostExecutorTests_ControlledAuthenticatedSource() = Array.fill policy.PolicyBytes 0 policy.PolicyBytes.Length 0uy captured let selection at revoke = selectionWith id id at revoke + let exerciseStoreCuts (current: PortableSupplierControlledSourceSelection) (transition: PortableSupplierAuthenticatedSourceTransition) = + let pending,_ = journal.Read transition.Original.Command |> SupplierCheckpointFixtures.ok + // The failed Stage cannot manufacture readback. A later explicit fresh read may + // authenticate persisted source bytes, independently of commit/durability evidence. + for point in [PortableWorkspaceExecutor.BeforeSourceWrite; PortableWorkspaceExecutor.BeforeSourceFileFlush + PortableWorkspaceExecutor.BeforeSourceReplacement; PortableWorkspaceExecutor.AfterSourceReplacementBeforeReadback + PortableWorkspaceExecutor.BeforeSourceColdReopen] do + use failedFixture = new HostExecutorFixture() + let sourceDirectory = Path.Combine(fixture.Policy.Runtime.StateRoot,"journal-v1") + let failedDirectory = Path.Combine(failedFixture.Policy.Runtime.StateRoot,"journal-v1") + Directory.CreateDirectory failedDirectory |> ignore + let sourcePath = Directory.GetFiles(sourceDirectory,"*.bin") |> Array.exactlyOne + let failedPath = Path.Combine(failedDirectory,Path.GetFileName sourcePath) + let before = File.ReadAllBytes sourcePath + File.WriteAllBytes(failedPath,before) + let failed = PortableWorkspaceExecutor.SupplierAdmissionJournal.CreateControlledFailureTest(failedFixture.Policy.Runtime.StateRoot,point) + Assert.Equal(SupplierSourceCommitUnknown,failed.StageControlledV4 transition) + let replaced = point = PortableWorkspaceExecutor.AfterSourceReplacementBeforeReadback || point = PortableWorkspaceExecutor.BeforeSourceColdReopen + let fresh = PortableWorkspaceExecutor.SupplierAdmissionJournal failedFixture.Policy.Runtime.StateRoot + let retained,durability = fresh.Read pending.Original.Command |> SupplierCheckpointFixtures.ok + Assert.Equal((if replaced then transition.State else pending),retained) + Assert.Equal(pending.Original,retained.Original) + Assert.Equal(pending.FirstFailure,retained.FirstFailure) + match durability with EvidenceUnknown _ -> () | _ -> failwith "failure cannot qualify directory durability" + if replaced then + let authenticated = PortableSupplierAdmission.reverifyControlledSourceV4 current retained |> SupplierCheckpointFixtures.ok + Assert.Equal(retained,authenticated.State) + Assert.Equal(SupplierSourceConflict,fresh.StageControlledV4 transition) + else + Assert.True((before = File.ReadAllBytes failedPath)) + Assert.True(PortableSupplierAdmission.reverifyControlledSourceV4 current retained |> Result.isError) + Assert.True(PortableSupplierAdmission.prepareAck retained |> Result.isError) + Assert.True(PortableSupplierAdmission.reserveHost retained |> Result.isError) + Assert.Empty(Directory.GetFiles(failedDirectory,"*.tmp")) + Assert.Equal(0,failedFixture.Runner.Runs) + Assert.Equal(0,failedFixture.Runner.Cleans) + Assert.Equal(0,failedFixture.Runner.Recoveries) match journal.StageV4(initial,None,None) with SupplierSourceReadback _ -> () | other -> failwithf "%A" other let mutable state = initial for name in ["creation";"genesis"] do @@ -1451,12 +1488,14 @@ type PortableHostExecutorTests_ControlledAuthenticatedSource() = match denied with HeldControlledSource _ -> () | _ -> failwith "valid fresh pair cannot authenticate an invalid retained result signature" let accepted = PortableSupplierAdmission.verifyAndAcceptControlledV4 (selection at false) pending.Original pending.Revision pending.CheckpointCustody.Value.StoreFence pending exchange |> SupplierCheckpointFixtures.ok let transition = match accepted with AuthenticatedControlledSource value -> value | HeldControlledSource value -> failwithf "signed source held: %A" value.FirstFailure + exerciseStoreCuts (selection at false) transition match journal.StageControlledV4 transition with SupplierSourceReadback _ -> () | other -> failwithf "%A" other Assert.Equal(SupplierSourceConflict,journal.StageControlledV4 transition) state <- transition.State // Continue the same real signed DAG through output wire and native acquisition. - let stageTransition outcome = + let stageTransition current outcome = let transition = match outcome with AuthenticatedControlledSource value -> value | HeldControlledSource held -> failwithf "signed output source held: %A" held.FirstFailure + exerciseStoreCuts current transition match journal.StageControlledV4 transition with SupplierSourceReadback _ -> () | other -> failwithf "%A" other Assert.Equal(SupplierSourceConflict,journal.StageControlledV4 transition) let reopened,_ = journal.Read transition.State.Original.Command |> SupplierCheckpointFixtures.ok @@ -1486,7 +1525,7 @@ type PortableHostExecutorTests_ControlledAuthenticatedSource() = | HeldControlledSource held -> Assert.True(held.FirstFailure.IsSome) | _ -> failwith "oversized event census authenticated" state <- PortableSupplierAdmission.verifyAndAcceptControlledV4 current pending.Original pending.Revision pending.CheckpointCustody.Value.StoreFence pending input - |> SupplierCheckpointFixtures.ok |> stageTransition + |> SupplierCheckpointFixtures.ok |> stageTransition current if name <> "append" then Assert.Equal(before.EventsBase64,state.EventsBase64) Assert.Equal(before.Head,state.Head) @@ -1516,7 +1555,7 @@ type PortableHostExecutorTests_ControlledAuthenticatedSource() = match again with HeldControlledSource sticky -> Assert.Equal(held,sticky) | _ -> failwith "held input acquired a success wrapper" | _ -> failwith "mismatched signed native pair authenticated" state <- PortableSupplierAdmission.verifyAndAcceptControlledV4 nativeSelection pending.Original pending.Revision pending.CheckpointCustody.Value.StoreFence pending nativeInput - |> SupplierCheckpointFixtures.ok |> stageTransition + |> SupplierCheckpointFixtures.ok |> stageTransition nativeSelection Assert.True(state.CheckpointCustody.Value.OutputCheckpointBase64.IsSome) Assert.Single(state.CheckpointCustody.Value.NativeAcquisitions) |> ignore let cold,durability = journal.Read state.Original.Command |> SupplierCheckpointFixtures.ok