Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 41 additions & 6 deletions src/FS.GG.Coord.Cli/BoardV2Application.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) ->
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/FS.GG.Coord.Cli/BoardV2Application.fsi
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
87 changes: 87 additions & 0 deletions tests/coord-board-v2-cli-join/BoardV2MigrationTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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

[<Fact>]
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"))

[<Fact>]
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"))

[<Fact>]
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<byte>(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"))

[<Fact>]
let ``actual dependent view filter refuses an intervening owner edit after create readback`` () =
use fixture = new Fixture()
Expand Down
9 changes: 9 additions & 0 deletions tests/coord-board-v2-cli-join/INSPECT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading