diff --git a/src/FS.GG.Coord.Cli/BoardV2Application.fs b/src/FS.GG.Coord.Cli/BoardV2Application.fs index 5b81e3091..be4690da0 100644 --- a/src/FS.GG.Coord.Cli/BoardV2Application.fs +++ b/src/FS.GG.Coord.Cli/BoardV2Application.fs @@ -329,6 +329,8 @@ module BoardV2Application = | _ -> Error "unsupported migration command or argument; no effects admitted") | [] -> Error "missing migration command" + // Private sibling that retains a refused migration-prepare's typed, bounded reason. + let private migrationPrepareFailurePath (reportPath: string) = reportPath + ".prepare-failure.json" let private migrationDigest (bytes: byte array) = SHA256.HashData bytes |> Convert.ToHexString |> _.ToLowerInvariant() let private privatePath (path: string) = if OperatingSystem.IsWindows() then invalidArg "path" "this private evidence route requires Unix permission verification" @@ -474,19 +476,35 @@ module BoardV2Application = let privateJson = privateJson syncDirectory let utf8 = System.Text.UTF8Encoding(false, true) let mutable ownsInvocation = false + // Prepare is read-only. Once it owns its new output identity, any refusal leaves a bounded + // private failure record beside the absent or partial report instead of only an exit code. + let mutable ownsPreparedOutput = false + let mutable prepareStage = "output-preconditions" + let mutable prepareError: Errors.IoError option = None let fail () = eprintfn "fsgg-coord-engine: board-v2 migration refused or incomplete; inspect private original invocation evidence; no automatic retry" 3 let invoke () = match command with | MigrationPrepare(scopePath, reportPath) -> - if scopePath = reportPath || File.Exists reportPath || Directory.Exists(reportPath + ".invocation") then invalidArg "path" "new distinct prepared output required" + let failurePath = migrationPrepareFailurePath reportPath + if scopePath = reportPath || File.Exists reportPath || Directory.Exists reportPath || Directory.Exists(reportPath + ".invocation") + || File.Exists failurePath || Directory.Exists failurePath then invalidArg "path" "new distinct prepared output required" + ownsPreparedOutput <- true + let classify (error: Errors.IoError) = + prepareError <- Some error + migrationErrorClass error + prepareStage <- "scope-read" let scopeJson = utf8.GetString(privateRead 262144L scopePath) - V2Migration.parseScope scopeJson |> Result.mapError migrationErrorClass + prepareStage <- "scope-parse" + V2Migration.parseScope scopeJson |> Result.mapError classify |> Result.bind (fun scope -> + prepareStage <- "executing-identity" migrationIdentity scope |> Result.bind (fun () -> - migrationStable connection scope |> Result.mapError migrationErrorClass)) + prepareStage <- "native-preparation" + migrationStable connection scope |> Result.mapError classify)) |> Result.map (fun prepared -> + prepareStage <- "prepared-report-write" privateJson reportPath (V2Migration.encodePrepared ({ prepared with PreparedPath = Some reportPath })) 0) | MigrationApply(preparedPath, expectedHash, owner, selection, windowUntil) -> @@ -647,8 +665,25 @@ module BoardV2Application = match V2Migration.prepare scope final with | Ok observed when observed.Changes.IsEmpty -> Ok 0 | _ -> Ok 3)))) - let terminal (failureCode: string) = + let terminal (failureCode: string) (exceptionType: string option) = match command with + | MigrationPrepare(_, reportPath) when ownsPreparedOutput -> + try + let bounded (text: string) = if text.Length <= 256 then text else text.Substring(0, 256) + let failureMetadata = + {| schema = "fs.gg.board-v2.migration-prepare-failure/1"; + state = "RefusedReadOnlyPreparation"; + nativeMutationDispatched = false; + automaticRetry = false; + stage = prepareStage; + failureCode = bounded failureCode; + errorClass = (prepareError |> Option.map migrationErrorClass |> Option.toObj); + reason = (prepareError |> Option.map migrationFailureReason |> Option.toObj); + exceptionType = (exceptionType |> Option.map bounded |> Option.toObj); + preparedReportPresent = File.Exists reportPath; + failedAt = clock.GetUtcNow() |} + privateJson (migrationPrepareFailurePath reportPath) (JsonSerializer.Serialize(failureMetadata)) + with _ -> () // The exit code and stderr remain; no retry or rewrite follows. | MigrationApply(preparedPath, _, _, _, _) -> let directory = preparedPath + ".invocation" if ownsInvocation && Directory.Exists directory then @@ -666,8 +701,8 @@ module BoardV2Application = try match invoke() with | Ok code -> code - | Error code -> terminal code - with _ -> terminal "invocation-io-or-unexpected-failure" + | Error code -> terminal code None + with error -> terminal "invocation-io-or-unexpected-failure" (Some(error.GetType().FullName)) let runMigrationWithCustody (connection: V2MigrationTransport.IConnection) (syncDirectory: string -> unit) (clock: TimeProvider) (arguments: string list) = try diff --git a/src/FS.GG.Coord.Cli/BoardV2Application.fsi b/src/FS.GG.Coord.Cli/BoardV2Application.fsi index 76db0793a..9e2e6d56a 100644 --- a/src/FS.GG.Coord.Cli/BoardV2Application.fsi +++ b/src/FS.GG.Coord.Cli/BoardV2Application.fsi @@ -72,6 +72,8 @@ module BoardV2Application = /// Linux x64 directory open/fsync after file Flush(true), including new-directory/parent barriers; /// unavailable/error barriers refuse before dispatch. Kernel acknowledgements do not establish /// physical-filesystem or crash qualification; those remain runtime custody requirements. + /// A prepare refusal after it owns NEW_PATH writes private NEW_PATH.prepare-failure.json with stage, + /// bounded code, typed class/reason and exception type only; an existing record refuses unchanged. /// Apply binds the separately selected exact prepared-file byte digest and a finite planning window. /// It reserves ORIGINAL_PATH.invocation-reserved and writes ORIGINAL_PATH.invocation evidence; /// reservations and original intent/response files are never overwritten, removed or replayed. diff --git a/tests/coord-board-v2-cli-join/BoardV2MigrationTests.fs b/tests/coord-board-v2-cli-join/BoardV2MigrationTests.fs index a8c5a9664..e52639250 100644 --- a/tests/coord-board-v2-cli-join/BoardV2MigrationTests.fs +++ b/tests/coord-board-v2-cli-join/BoardV2MigrationTests.fs @@ -625,6 +625,93 @@ module BoardV2MigrationTests = use report = JsonDocument.Parse(File.ReadAllText path) Assert.Contains("lacks project update capability", report.RootElement.GetProperty("ownerActions").GetRawText()) + let private prepareFailure (report: string) = + let path = report + ".prepare-failure.json" + Assert.True(File.Exists path, "refused prepare must retain its private failure record") + Assert.Equal(UnixFileMode.UserRead ||| UnixFileMode.UserWrite, File.GetUnixFileMode path) + let document = JsonDocument.Parse(File.ReadAllText path) + let root = document.RootElement + Assert.Equal("fs.gg.board-v2.migration-prepare-failure/1", root.GetProperty("schema").GetString()) + Assert.Equal("RefusedReadOnlyPreparation", root.GetProperty("state").GetString()) + Assert.False(root.GetProperty("nativeMutationDispatched").GetBoolean()) + Assert.False(root.GetProperty("automaticRetry").GetBoolean()) + root + + [] + let ``actual CLI prepare refusal retains its typed transport reason without a prepared report`` () = + use fixture = new Fixture() + fixture.Native.Transform <- fun _ _ -> Error(Errors.Transport "synthetic connection reset") + let code, path = fixture.Prepare "prepare-transport-refusal" + Assert.Equal(3, code) + Assert.False(File.Exists path) + Assert.False(Directory.Exists(path + ".invocation")) + Assert.Empty fixture.Native.Mutations + let root = prepareFailure path + Assert.Equal("native-preparation", root.GetProperty("stage").GetString()) + Assert.Equal("transport", root.GetProperty("failureCode").GetString()) + Assert.Equal("transport", root.GetProperty("errorClass").GetString()) + Assert.Equal("transport", root.GetProperty("reason").GetString()) + Assert.Equal(JsonValueKind.Null, root.GetProperty("exceptionType").ValueKind) + Assert.False(root.GetProperty("preparedReportPresent").GetBoolean()) + Assert.DoesNotContain("synthetic connection reset", File.ReadAllText(path + ".prepare-failure.json")) + + [] + let ``actual CLI prepare drift and unexpected read failure retain bounded stage and class`` () = + use drift = new Fixture() + let mutable collections = 0 + drift.Native.BeforeRead <- fun (request: Transport.Request) -> + match request.Body with + | Transport.Query(document, _) when document.Contains "viewer{id login}" -> + collections <- collections + 1 + if collections = 2 then + let fields = + drift.Native.Snapshot.Fields + |> List.map (fun (field: Field) -> + if field.Id = scope.StatusFieldId then { field with Options = (field.Options |> List.map (fun (option: SelectOption) -> { option with Description = "owner changed descriptor" })) } + else field) + drift.Native.Snapshot <- { drift.Native.Snapshot with Fields = fields } + | _ -> () + let code, path = drift.Prepare "prepare-drift" + Assert.Equal(3, code) + Assert.False(File.Exists path) + let root = prepareFailure path + Assert.Equal("native-preparation", root.GetProperty("stage").GetString()) + Assert.Equal("malformed-or-refused", root.GetProperty("errorClass").GetString()) + use thrown = new Fixture() + thrown.Native.BeforeRead <- fun _ -> raise (InvalidOperationException "synthetic private detail /home/secret") + let code, path = thrown.Prepare "prepare-exception" + Assert.Equal(3, code) + Assert.Empty thrown.Native.Mutations + let root = prepareFailure path + Assert.Equal("native-preparation", root.GetProperty("stage").GetString()) + Assert.Equal("invocation-io-or-unexpected-failure", root.GetProperty("failureCode").GetString()) + Assert.Equal("System.InvalidOperationException", root.GetProperty("exceptionType").GetString()) + Assert.DoesNotContain("/home/secret", File.ReadAllText(path + ".prepare-failure.json")) + + [] + let ``actual CLI prepare success and unowned output refusals write no failure record`` () = + use fixture = new Fixture() + let code, path = fixture.Prepare "prepare-success" + Assert.Equal(0, code) + Assert.False(File.Exists(path + ".prepare-failure.json")) + let original = File.ReadAllBytes path + let input = Path.Combine(fixture.Directory, "prepare-success-scope.json") + Assert.Equal(3, fixture.Run [ "migration-prepare"; "--scope-file"; input; "--report-file"; path ]) + Assert.Equal(original, File.ReadAllBytes path) + Assert.False(File.Exists(path + ".prepare-failure.json")) + let fresh = Path.Combine(fixture.Directory, "prepare-collision-prepared.json") + File.WriteAllText(fresh + ".prepare-failure.json", "first retained failure") + let readsBefore = fixture.Native.Requests.Length + Assert.Equal(3, fixture.Run [ "migration-prepare"; "--scope-file"; input; "--report-file"; fresh ]) + Assert.Equal(readsBefore, fixture.Native.Requests.Length) + Assert.False(File.Exists fresh) + let directoryReport = Path.Combine(fixture.Directory, "prepare-directory-prepared.json") + Directory.CreateDirectory directoryReport |> ignore + Assert.Equal(3, fixture.Run [ "migration-prepare"; "--scope-file"; input; "--report-file"; directoryReport ]) + Assert.Equal(readsBefore, fixture.Native.Requests.Length) + Assert.False(File.Exists(directoryReport + ".prepare-failure.json")) + Assert.Equal("first retained failure", File.ReadAllText(fresh + ".prepare-failure.json")) + [] let ``actual dependent view filter refuses an intervening owner edit after create readback`` () = use fixture = new Fixture() diff --git a/tests/coord-board-v2-cli-join/INSPECT.md b/tests/coord-board-v2-cli-join/INSPECT.md index 3b7d72a71..59c59e517 100644 --- a/tests/coord-board-v2-cli-join/INSPECT.md +++ b/tests/coord-board-v2-cli-join/INSPECT.md @@ -180,6 +180,15 @@ for the read-only report. Apply binds selected prepared bytes, actual executing identities, native principal/capability, exact fresh scope and a finite planning window. Copying unchanged prepared bytes to a different path refuses before native reads or a new reservation. +Once preparation owns a new report path, a refusal writes a private `0600` +`REPORT.prepare-failure.json` beside the absent or partial report +(`fs.gg.board-v2.migration-prepare-failure/1`). It records the stage, the bounded failure code, +the typed error class and allowlisted reason, the exception type name only, whether report bytes +exist, and the injected-clock failure time. Exception messages, paths and response bytes are never +copied. A pre-existing failure record refuses the new preparation without being rewritten. The +record does not authorize a retry, and it does not turn a later successful preparation into an +explanation of this one. + The original path owns its exclusive reservation and private invocation directory. Exact intent and request bytes precede each one-shot send; file flush and production Linux x64 directory-entry barriers must succeed. Unsupported barriers refuse; source review is not runtime filesystem qualification.