diff --git a/docs/governance-design/neutral-capability-bindings.md b/docs/governance-design/neutral-capability-bindings.md index e549803a..a58ab96b 100644 --- a/docs/governance-design/neutral-capability-bindings.md +++ b/docs/governance-design/neutral-capability-bindings.md @@ -270,3 +270,28 @@ diagnostic. It emits no legacy execution or store effects and writes no provider Program and Interpreter remain on the legacy route. Package-backed parsing, independent policy conversion, pre-dispatch revalidation, qualified provider execution and evidence consumers remain open. This planning join does not supply actual producer-to-Verify acceptance or complete C2. + +### Controlled capture retention boundary (2026-10-15) + +The [capture interpreter](../../src/FS.GG.Governance.VerifyCommand/ProviderContextCapture.fs) +keeps native operations and its monotonic clock on each original session. Production `prepare` +selects the existing Linux calls; friend-visible test construction installs inert operations before +acquisition. Each native result carries the errno captured immediately by its wrapper. No global +switch changes an existing session. Controlled sessions refuse native root-lease duplication; +production retains the held-root lease and direct-execution borrower fences introduced in +[#453](https://github.com/FS-GG/FS.GG.Governance/pull/453). + +A task remains registered before acquisition and stays with the same caller owner after work or +cleanup expiry. A returning read rechecks the original work boundary before accepting its bytes. +Inspection neither waits for that task nor renews its ends. Settlement still requires released +model state, zero retained descriptors and root leases, no close uncertainty and completed actual +activity. A failed close is never retried, including after another owner reuses its numeric identity. + +The ten [controlled interpreter cases](../../tests/FS.GG.Governance.VerifyCommand.Tests/ProviderContextCaptureTests.fs) +cover finite success/revalidation, pending reads with prior bounded evidence, cancellation, pending +revalidation and borrowing, uncertain close, caps, failure ordering, binding drift and one-use capture. +Paused inert reads have finite gates released and joined in `finally`; uncertain closes use only +synthetic descriptors. These controls exercise the real interpreter but do not demonstrate a blocked +kernel read, a physical close failure or whole-worker teardown. The existing physical fixture stays +opt-in and must not run an indefinitely retained uncertain-close case without separately qualified +teardown. Physical retention, provider loader/Verify acceptance and C2 completion remain open. diff --git a/docs/roadmaps/governance-423-neutral-capability-bindings.md b/docs/roadmaps/governance-423-neutral-capability-bindings.md index 9e6dc185..da0b92e7 100644 --- a/docs/roadmaps/governance-423-neutral-capability-bindings.md +++ b/docs/roadmaps/governance-423-neutral-capability-bindings.md @@ -325,3 +325,32 @@ not a new candidate, and no-push candidates cannot be promoted by this route. This source amendment does not qualify a release archive, dispatch, publish, reconcile original holds or complete C2/C3/C4. Runtime carrier publication, exact ReferenceGateSet 1.8.0 adoption, Templates/public receivers and broader release completion retain their applicable evidence gates. + +### GOV423-C2 controlled retention source window (2026-10-15) + +Owner: Governance source lane. Status: source prepared; hosted qualification pending. C2 remains +unchecked. This extends the existing capture milestone and preserves all earlier failures and custody +unknowns. The historical 15-case report above describes #449; #453 subsequently added held-root lease +coverage and reported 20 physical controls. Those reports do not qualify blocked kernel reads or +uncertain physical closes. + +The bounded seven-path change adds internal per-session native/clock operations, immutable ownership +facts and ten finite controls through the actual capture interpreter. Native result/errno association, +original task registration, one-use acquisition, byte caps, cooperative comparisons and the public +signature remain preserved. A post-read work check prevents a late read from accepting new bytes. +Synthetic sessions cannot duplicate native leases; production's outstanding lease fences stay intact. +The surface guard explicitly rejects exported operation/test types and entry points. + +Local validation is limited to source, exact touch set, diff and relative links. No local CLR or native +fixture execution is admitted. Normal PR CI may qualify compilation and controlled/default tests with +physical capture still pending. Preserve its first failing run and exact successor head; hosted green +establishes source qualification only. The source PR and its exact checks supply delivery evidence; +publication, installed adoption and physical acceptance remain separate. + +The remaining physical gate needs an accountable supplying owner, an authentic regular-file blocked +read accepted by production acquisition, independently observed uncertain close and demonstrated +whole-worker/resource teardown. No backend is qualified by this window. Keep those physical cases +pending and retain the original caller owner and failure evidence. The existing provider loader, +Program/Verify integration, stronger profiles, coherent SDD consumer joins and C3/C4 milestones remain +open. Atlas capture remains an asynchronous owning-archive follow-up for the programme integrator: +`governance/provider-capture-retention-boundary`. diff --git a/src/FS.GG.Governance.VerifyCommand/FS.GG.Governance.VerifyCommand.fsproj b/src/FS.GG.Governance.VerifyCommand/FS.GG.Governance.VerifyCommand.fsproj index ba27ae6e..5a8fa99f 100644 --- a/src/FS.GG.Governance.VerifyCommand/FS.GG.Governance.VerifyCommand.fsproj +++ b/src/FS.GG.Governance.VerifyCommand/FS.GG.Governance.VerifyCommand.fsproj @@ -28,6 +28,10 @@ false + + + + diff --git a/src/FS.GG.Governance.VerifyCommand/ProviderContextCapture.fs b/src/FS.GG.Governance.VerifyCommand/ProviderContextCapture.fs index 932a8e73..cb5f15e6 100644 --- a/src/FS.GG.Governance.VerifyCommand/ProviderContextCapture.fs +++ b/src/FS.GG.Governance.VerifyCommand/ProviderContextCapture.fs @@ -133,6 +133,38 @@ module ProviderContextCapture = [] extern int close(int fd) + type internal Operations = + { Current: unit -> FS.GG.Governance.GateExecution.Model.MonotonicInstant + OpenAt: int -> string -> uint64 -> uint64 -> int64 * int + Stat: int -> byte[] -> int * int + OpenRead: string -> int * int + Read: int -> byte[] -> int64 * int + Seek: int -> int64 * int + Close: int -> int * int } + type internal Ownership = + { PendingActivity: bool; RetainedDescriptors: int; CloseUncertain: bool; BorrowedRoots: int } + let nativeOperations : Operations = + { Current = FS.GG.Governance.GateExecution.Interpreter.currentInstant + OpenAt = fun directory path flags resolve -> + let mutable how=Native.OpenHow(flags,resolve) + let result=Native.openat2(437L,directory,path,&how,24UL) + result,System.Runtime.InteropServices.Marshal.GetLastPInvokeError() + Stat = fun fd bytes -> + let result=Native.statx(fd,"",4352,0x17ffu,bytes) + result,System.Runtime.InteropServices.Marshal.GetLastPInvokeError() + OpenRead = fun path -> + let result=Native.openReadOnly(path,0x80000) + result,System.Runtime.InteropServices.Marshal.GetLastPInvokeError() + Read = fun fd bytes -> + let result=Native.read(fd,bytes,uint64 bytes.Length) + result,System.Runtime.InteropServices.Marshal.GetLastPInvokeError() + Seek = fun fd -> + let result=Native.lseek(fd,0L,0) + result,System.Runtime.InteropServices.Marshal.GetLastPInvokeError() + Close = fun fd -> + let result=Native.close fd + result,System.Runtime.InteropServices.Marshal.GetLastPInvokeError() } + type Metadata = { Identity: ObjectIdentity; Mode: uint16; Size: uint64 ModifiedSeconds: int64; ModifiedNanoseconds: uint32 @@ -143,6 +175,8 @@ module ProviderContextCapture = PathFd: int; DataFd: int; Metadata: Metadata; Observation: DocumentObservation } type OwnerState = { Request: Request + Operations: Operations + NativeLeaseAllowed: bool Sync: obj mutable Model: Model Fds: System.Collections.Generic.HashSet @@ -158,32 +192,31 @@ module ProviderContextCapture = member _.Operation = state.Request.Operation member _.State = state - let current () = FS.GG.Governance.GateExecution.Interpreter.currentInstant() + let current (state:OwnerState) = state.Operations.Current() let transition (state:OwnerState) msg = lock state.Sync (fun () -> state.Model <- update msg state.Model |> fst) let report (state:OwnerState) cause = transition state (FailureObserved cause) - let errno () = System.Runtime.InteropServices.Marshal.GetLastPInvokeError() |> string let fault role detail = raise (CaptureFault(AcquisitionFailed(role,detail))) let checkWork (state:OwnerState) = - let now=current() + let now=current state if state.Request.Cancellation.IsCancellationRequested then raise (CaptureFault CancellationRequested) if now.Ticks>=state.Request.Budget.WorkEnd.Ticks then raise (CaptureFault WorkDeadlineReached) if lock state.Sync (fun () -> Option.isSome state.Model.FirstFailure) then raise (CaptureFault OperationInProgress) - let own (state:OwnerState) role fd = - if fd<0 then fault role ("native acquisition errno="+errno()) + let own (state:OwnerState) role (fd,error) = + if fd<0 then fault role ("native acquisition errno="+string error) lock state.Sync (fun () -> state.Fds.Add fd |> ignore) fd let pathOpen (state:OwnerState) role directory path flags resolve = checkWork state - let mutable how=Native.OpenHow(flags,resolve) - let fd=Native.openat2(437L,directory,path,&how,24UL) + let fd,error=state.Operations.OpenAt directory path flags resolve if fd<0L then - if System.Runtime.InteropServices.Marshal.GetLastPInvokeError()=38 then raise (CaptureFault(NativePrerequisiteUnavailable "openat2 unavailable")) - fault role ("openat2 errno="+errno()) - own state role (int fd) + if error=38 then raise (CaptureFault(NativePrerequisiteUnavailable "openat2 unavailable")) + fault role ("openat2 errno="+string error) + own state role (int fd,error) let metadata (state:OwnerState) role fd = checkWork state let bytes=Array.zeroCreate 256 - if Native.statx(fd,"",4352,0x17ffu,bytes)<>0 then fault role ("statx errno="+errno()) + let result,error=state.Operations.Stat fd bytes + if result<>0 then fault role ("statx errno="+string error) let u32 offset=System.BitConverter.ToUInt32(bytes,offset) let u64 offset=System.BitConverter.ToUInt64(bytes,offset) let mask=u32 0 @@ -211,8 +244,9 @@ module ProviderContextCapture = root let closeTracked (state:OwnerState) fd = // Linux close failure is not retried: numeric fd reuse cannot establish continued ownership. - if (current()).Ticks>=state.Request.Budget.CleanupEnd.Ticks then raise (CaptureFault CleanupDeadlineReached) - if Native.close fd=0 then lock state.Sync (fun () -> state.Fds.Remove fd |> ignore) + if (current state).Ticks>=state.Request.Budget.CleanupEnd.Ticks then raise (CaptureFault CleanupDeadlineReached) + let result,_=state.Operations.Close fd + if result=0 then lock state.Sync (fun () -> state.Fds.Remove fd |> ignore) else lock state.Sync (fun () -> state.CloseUncertain <- true) raise (CaptureFault OwnedActivitiesPending) @@ -220,13 +254,14 @@ module ProviderContextCapture = let observed=metadata state role fd if observed.Mode &&& 0xf000us <> 0x8000us || not (sameObject original observed) then fault role "held object is not the selected regular file" checkWork state - let data=own state role (Native.openReadOnly("/proc/self/fd/"+string fd,0x80000)) + let data=own state role (state.Operations.OpenRead ("/proc/self/fd/"+string fd)) let reopened=metadata state role data if not (sameObject original reopened) then fault role "procfd reopen changed object" data let reset (state:OwnerState) role fd = checkWork state - if Native.lseek(fd,0L,0)<>0L then fault role ("held regular-file reset errno="+errno()) + let result,error=state.Operations.Seek fd + if result<>0L then fault role ("held regular-file reset errno="+string error) let rawDigest (bytes: byte[]) count = System.Security.Cryptography.SHA256.HashData(System.ReadOnlySpan(bytes,0,count)) |> System.Convert.ToHexString |> fun digest -> digest.ToLowerInvariant() @@ -240,9 +275,11 @@ module ProviderContextCapture = reset state role fd while reading do checkWork state - let amount=Native.read(fd,scratch,uint64 scratch.Length) - if amount<0L then fault role ("read errno="+errno()) - elif amount=0L then reading <- false + let amount,error=state.Operations.Read fd scratch + if amount<0L then fault role ("read errno="+string error) + // A returning read cannot add bytes after its original work end or an observed failure. + checkWork state + if amount=0L then reading <- false elif amount>int64 (capacity-count) then raise (CaptureFault(ByteLimitExceeded role)) else System.Array.Copy(scratch,0,bytes,count,int amount) @@ -256,9 +293,10 @@ module ProviderContextCapture = reset state document.Role document.DataFd while reading do checkWork state - let amount=Native.read(document.DataFd,scratch,uint64 scratch.Length) - if amount<0L then fault document.Role ("comparison read errno="+errno()) - elif amount=0L then reading <- false + let amount,error=state.Operations.Read document.DataFd scratch + if amount<0L then fault document.Role ("comparison read errno="+string error) + checkWork state + if amount=0L then reading <- false elif amount>int64 (document.Observation.Bytes.Length-count) then raise (CaptureFault(InputChanged document.Role)) else for index=0 to int amount-1 do @@ -311,7 +349,7 @@ module ProviderContextCapture = let descriptors,uncertain,borrowed=lock state.Sync (fun () -> state.Fds |> Seq.toList,state.CloseUncertain,state.RootLeases.Count>0) if not uncertain && not borrowed then for fd in descriptors do closeTracked state fd - if (current()).Ticks>=state.Request.Budget.CleanupEnd.Ticks then report state CleanupDeadlineReached + if (current state).Ticks>=state.Request.Budget.CleanupEnd.Ticks then report state CleanupDeadlineReached transition state OwnedResourcesSettled let runOwned (state:OwnerState) action = try action() @@ -336,7 +374,7 @@ module ProviderContextCapture = let mutable waiting=true while waiting do let completed=lock state.Sync (fun () -> state.Activity |> Option.forall(fun task -> task.IsCompleted)) - let now=current() + let now=current state if now.Ticks>=state.Request.Budget.CleanupEnd.Ticks then report state CleanupDeadlineReached waiting <- false @@ -349,7 +387,7 @@ module ProviderContextCapture = if state.Request.Cancellation.IsCancellationRequested then report state CancellationRequested System.Threading.Thread.Sleep 1 let snapshot (state:OwnerState) = - let now=current() + let now=current state lock state.Sync (fun () -> {Operation=state.Request.Operation;Model=state.Model;Roots=state.RootObservations Documents=state.Documents |> List.map(fun held -> held.Observation) @@ -357,16 +395,23 @@ module ProviderContextCapture = WorkEndReached=now.Ticks>=state.Request.Budget.WorkEnd.Ticks CleanupEndReached=now.Ticks>=state.Request.Budget.CleanupEnd.Ticks CancellationObserved=state.Request.Cancellation.IsCancellationRequested}) - let prepare (request:Request) = - let errors=validateRequest (current()) request + let prepareWith nativeLeaseAllowed (operations:Operations) (request:Request) = + let errors=validateRequest (operations.Current()) request let platform=System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Linux) && System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture=System.Runtime.InteropServices.Architecture.X64 && request.Selection.Platform="linux-x64" - let errors=if platform then errors else errors @ [UnsupportedPlatform] + let errors=if not nativeLeaseAllowed || platform then errors else errors @ [UnsupportedPlatform] if not (List.isEmpty errors) then Error errors else - Ok(CaptureSession({Request=request;Sync=obj();Model=init();Fds=System.Collections.Generic.HashSet() + Ok(CaptureSession({Request=request;Operations=operations;NativeLeaseAllowed=nativeLeaseAllowed;Sync=obj();Model=init();Fds=System.Collections.Generic.HashSet() Roots=[];RootObservations=[];Documents=[];Activity=None;CloseUncertain=false;RootLeases=System.Collections.Generic.List<_>()})) + let prepare request = prepareWith true nativeOperations request + let internal prepareControlled operations request = prepareWith false operations request + let internal ownership (session:CaptureSession) : Ownership = + let state=session.State + lock state.Sync (fun () -> + {PendingActivity=state.Activity |> Option.exists(fun task -> not task.IsCompleted) + RetainedDescriptors=state.Fds.Count;CloseUncertain=state.CloseUncertain;BorrowedRoots=state.RootLeases.Count}) let inspect (session:CaptureSession) = snapshot session.State let capture (session:CaptureSession) = let state=session.State @@ -412,15 +457,15 @@ module ProviderContextCapture = lock state.Sync (fun () -> let active=state.Activity |> Option.exists(fun task -> not task.IsCompleted) if active || state.CloseUncertain || state.RootLeases.Count>0 then - if (current()).Ticks>=state.Request.Budget.CleanupEnd.Ticks then report state CleanupDeadlineReached + if (current state).Ticks>=state.Request.Budget.CleanupEnd.Ticks then report state CleanupDeadlineReached Error OwnedActivitiesPending elif state.Model.Phase=Released && state.Fds.Count=0 then Ok() elif state.Fds.Count=0 then - if (current()).Ticks>=state.Request.Budget.CleanupEnd.Ticks then report state CleanupDeadlineReached + if (current state).Ticks>=state.Request.Budget.CleanupEnd.Ticks then report state CleanupDeadlineReached transition state ReleaseRequested transition state OwnedResourcesSettled Ok() - elif (current()).Ticks>=state.Request.Budget.CleanupEnd.Ticks then + elif (current state).Ticks>=state.Request.Budget.CleanupEnd.Ticks then report state CleanupDeadlineReached Error CleanupDeadlineReached else @@ -433,6 +478,7 @@ module ProviderContextCapture = let state = session.State lock state.Sync (fun () -> if original.Model.Phase<>Ready || original.Model.FirstFailure.IsSome || state.Model.Phase<>Ready then Error CaptureNotReady + elif not state.NativeLeaseAllowed then Error (NativePrerequisiteUnavailable "controlled sessions cannot duplicate native directory leases") else match state.Roots |> List.tryFind(fun root -> root.Path=state.Request.RepositoryRoot) with | None -> Error CaptureNotReady diff --git a/src/FS.GG.Governance.VerifyCommand/ProviderContextCapture.fsi b/src/FS.GG.Governance.VerifyCommand/ProviderContextCapture.fsi index 8b27eca4..56ee4e50 100644 --- a/src/FS.GG.Governance.VerifyCommand/ProviderContextCapture.fsi +++ b/src/FS.GG.Governance.VerifyCommand/ProviderContextCapture.fsi @@ -93,3 +93,19 @@ module ProviderContextCapture = /// Capture release refuses while the borrowed lease or its direct execution borrowers remain owned. val borrowRepositoryRoot: CaptureSession -> Result + + /// Per-session inert interpreter controls. Each return pairs the native result with its immediate errno. + /// Installed before acquisition; never selected by production prepare or a process-global switch. + type internal Operations = + { Current: unit -> FS.GG.Governance.GateExecution.Model.MonotonicInstant + OpenAt: int -> string -> uint64 -> uint64 -> int64 * int + Stat: int -> byte[] -> int * int + OpenRead: string -> int * int + Read: int -> byte[] -> int64 * int + Seek: int -> int64 * int + Close: int -> int * int } + type internal Ownership = + { PendingActivity: bool; RetainedDescriptors: int; CloseUncertain: bool; BorrowedRoots: int } + /// No platform/native call; request validation still uses this session's original clock and ends. + val internal prepareControlled: Operations -> Request -> Result + val internal ownership: CaptureSession -> Ownership diff --git a/tests/FS.GG.Governance.VerifyCommand.Tests/ProviderContextCaptureTests.fs b/tests/FS.GG.Governance.VerifyCommand.Tests/ProviderContextCaptureTests.fs index ccb74370..b64b9a7b 100644 --- a/tests/FS.GG.Governance.VerifyCommand.Tests/ProviderContextCaptureTests.fs +++ b/tests/FS.GG.Governance.VerifyCommand.Tests/ProviderContextCaptureTests.fs @@ -416,3 +416,201 @@ let actualCases = baseActualCases @ [ let nativeTests = testList "ProviderContextCapture Actual" (actualCases |> List.map(fun (name,run) -> if nativeEnabled then testCase name run else ptestCase name run)) + +// Controlled real interpreter: synthetic descriptors and finite gates, no kernel acquisition. +// Every paused activity is unblocked and joined in finally, even after an assertion fails. +type private ControlledFixture(blockRead: int, failClose: bool) = + let entered = new Threading.ManualResetEventSlim(false) + let resume = new Threading.ManualResetEventSlim(false) + let descriptors = Collections.Generic.Dictionary() + let positions = Collections.Generic.Dictionary() + let closes = Collections.Generic.List() + let mutable next = 1000 + let mutable reads = 0 + let mutable ticks = 10L + let mutable changed = false + let payload = Text.Encoding.UTF8.GetBytes "bounded-document" + let digest = Security.Cryptography.SHA256.HashData(payload) |> Convert.ToHexString |> fun s -> s.ToLowerInvariant() + let allocate path = + next <- next + 1 + descriptors.Add(next,path) + positions.Add(next,0) + next + let write (bytes:byte[]) offset (value:byte[]) = Array.Copy(value,0,bytes,offset,value.Length) + let operations : Operations = + { Current = fun () -> {now with Ticks=Threading.Interlocked.Read(&ticks)} + OpenAt = fun directory path _ _ -> + let absolute = if directory = -100 then path else descriptors[directory]+"/"+path + int64 (allocate absolute),0 + Stat = fun fd bytes -> + let path=descriptors[fd] + let directory=path="/repo" || path="/host" + write bytes 0 (BitConverter.GetBytes 0x17ffu) + write bytes 28 (BitConverter.GetBytes (if directory then 0x4000us else 0x8000us)) + let identity = if path="/repo" then 1UL elif path="/host" then 2UL elif path.Contains("provenance") then 3UL else 4UL + write bytes 32 (BitConverter.GetBytes (if changed && not directory then identity+10UL else identity)) + write bytes 40 (BitConverter.GetBytes (if directory then 0UL else uint64 payload.Length)) + write bytes 144 (BitConverter.GetBytes 1UL) + 0,0 + OpenRead = fun path -> + let original=Int32.Parse(path.Substring(path.LastIndexOf('/')+1)) + allocate descriptors[original],0 + Read = fun fd bytes -> + reads <- reads + 1 + if reads=blockRead then + entered.Set() + if not (resume.Wait(TimeSpan.FromSeconds 5.)) then failwith "finite controlled read gate expired" + let position=positions[fd] + let count=min bytes.Length (payload.Length-position) + Array.Copy(payload,position,bytes,0,count) + positions[fd] <- position+count + int64 count,0 + Seek = fun fd -> positions[fd] <- 0; 0L,0 + Close = fun fd -> closes.Add fd; (if failClose then -1,5 else 0,0) } + member _.Operations = operations + member _.Request = {request with Selection={request.Selection with ProvenanceSha256=digest;PolicySha256=digest}} + member _.Advance value = Threading.Interlocked.Exchange(&ticks,value) |> ignore + member _.ChangeBinding() = changed <- true + member _.CloseAttempts = closes |> Seq.toList + member _.ReuseUncertainIdentity() = + // Simulate another owner's reuse of the failed close's numeric descriptor. + descriptors[closes[0]] <- "/unrelated-owner" + positions[closes[0]] <- 0 + member _.WaitBlocked() = Expect.isTrue (entered.Wait(TimeSpan.FromSeconds 3.)) "controlled read entered within finite bound" + member _.Resume() = resume.Set() + interface IDisposable with + member _.Dispose() = resume.Dispose(); entered.Dispose() + +let private controlledSession (fixture:ControlledFixture) request = + match prepareControlled fixture.Operations request with + | Ok session -> session + | Error errors -> failtestf "controlled prepare refused: %A" errors +let private joined (task:Threading.Tasks.Task<'a>) = + Expect.isTrue (task.Wait(TimeSpan.FromSeconds 8.)) "actual controlled activity returned within finite bound" + task.GetAwaiter().GetResult() +let private settleControlled session = + Expect.isTrue + (Threading.SpinWait.SpinUntil((fun () -> not (ownership session).PendingActivity),TimeSpan.FromSeconds 3.)) + "actual owner task finished" +let private releaseControlled session = + release session |> ignore + settleControlled session + Expect.equal (release session) (Ok ()) "settled finite owner releases" + Expect.equal (inspect session).Settlement ResourcesReleased "all release predicates hold" +let private pausedControl action = + use fixture = new ControlledFixture(3,false) + let session=controlledSession fixture fixture.Request + let caller=Threading.Tasks.Task.Run(fun () -> capture session) + try + fixture.WaitBlocked() + action fixture session caller + finally + fixture.Resume() + joined caller |> ignore + settleControlled session + +[] +let controlledTests = + testList "ProviderContextCapture Controlled interpreter" [ + testCase "finite success and original-object revalidation" (fun () -> + use fixture = new ControlledFixture(0,false) + let session=controlledSession fixture fixture.Request + Expect.equal (capture session).Model.Phase Ready "real comparison flow ready" + Expect.equal (revalidate session).Model.Phase Ready "same objects revalidated" + Expect.equal (inspect session).Documents.Length 2 "complete selected set" + releaseControlled session) + testCase "pending read retains original task descriptors and bounded evidence" (fun () -> + pausedControl (fun fixture session caller -> + let before=inspect session + Expect.equal before.Documents.Length 1 "first document retained before policy block" + Expect.isTrue (before.Documents[0].Bytes.Length<=fixture.Request.Limits.PerFileBytes) "bounded original evidence" + Expect.isTrue (ownership session).PendingActivity "registered actual activity" + fixture.Advance 100L + Expect.isTrue (Threading.SpinWait.SpinUntil((fun () -> (inspect session).Model.FirstFailure=Some WorkDeadlineReached),TimeSpan.FromSeconds 2.)) "original work end observed" + fixture.Advance 200L + let retained=joined caller + Expect.equal retained.Settlement Retained "cleanup expiry retains custody" + Expect.equal retained.Model.FirstFailure (Some WorkDeadlineReached) "first cause retained" + Expect.contains retained.Model.SecondaryFailures CleanupDeadlineReached "later cleanup cause retained" + Expect.isGreaterThan (ownership session).RetainedDescriptors 0 "descriptors retained" + Expect.equal (release session) (Error OwnedActivitiesPending) "release refuses pending read" + fixture.Resume() + settleControlled session + let late=inspect session + Expect.equal late.Documents before.Documents "no replacement output after failed read" + Expect.equal late.Model.FirstFailure retained.Model.FirstFailure "late read cannot erase first cause" + Expect.equal late.Settlement Retained "expired cleanup cannot release descriptors" + Expect.equal (release session) (Error CleanupDeadlineReached) "original cleanup end never renews")) + testCase "cancellation before cleanup settles actual activity" (fun () -> + use fixture = new ControlledFixture(1,false) + use cancelled = new Threading.CancellationTokenSource() + let session=controlledSession fixture {fixture.Request with Cancellation=cancelled.Token} + let caller=Threading.Tasks.Task.Run(fun () -> capture session) + try + fixture.WaitBlocked() + cancelled.Cancel() + Expect.isTrue (Threading.SpinWait.SpinUntil((fun () -> (inspect session).Model.FirstFailure=Some CancellationRequested),TimeSpan.FromSeconds 2.)) "cancellation observed" + finally fixture.Resume(); joined caller |> ignore; settleControlled session + Expect.equal (inspect session).Model.FirstFailure (Some CancellationRequested) "first cause survives retirement" + releaseControlled session) + testCase "pending revalidation cannot replace task or create readiness" (fun () -> + pausedControl (fun fixture session caller -> + let retry=Threading.Tasks.Task.Run(fun () -> revalidate session) + try + Expect.isTrue (Threading.SpinWait.SpinUntil((fun () -> (inspect session).Model.FirstFailure=Some OperationInProgress),TimeSpan.FromSeconds 2.)) "pending revalidation recorded before expiry" + finally fixture.Advance 200L + let rejected=joined retry + Expect.equal rejected.Model.Phase Retiring "pending revalidation refuses" + Expect.equal rejected.Model.FirstFailure (Some OperationInProgress) "pending task is original cause" + Expect.equal rejected.Settlement Retained "same owner retained" + joined caller |> ignore)) + testCase "pending borrow refuses and keeps same owner" (fun () -> + pausedControl (fun fixture session caller -> + fixture.Advance 200L + Expect.isError (borrowRepositoryRoot session) "pending owner gives no lease" + Expect.equal (ownership session).BorrowedRoots 0 "no native borrowing attempted" + Expect.isTrue (ownership session).PendingActivity "original task remains" + joined caller |> ignore)) + testCase "uncertain close stays sticky and synthetic identity is never retried" (fun () -> + use fixture = new ControlledFixture(0,true) + let session=controlledSession fixture fixture.Request + let observation=capture session + Expect.equal observation.Model.FirstFailure (Some OwnedActivitiesPending) "failed native-shaped close retained" + let attempts=fixture.CloseAttempts + Expect.equal attempts.Length 1 "one close attempt" + fixture.ReuseUncertainIdentity() + for _ in 1..3 do + Expect.equal (release session) (Error OwnedActivitiesPending) "uncertain numeric identity cannot retire" + Expect.isError (borrowRepositoryRoot session) "uncertainty cannot create lease" + Expect.equal fixture.CloseAttempts attempts "no numeric descriptor retry" + Expect.isTrue (ownership session).CloseUncertain "sticky uncertainty" + Expect.equal (inspect session).Settlement Retained "no false settlement") + testCase "byte limit first cause survives completed retirement" (fun () -> + use fixture = new ControlledFixture(0,false) + let session=controlledSession fixture {fixture.Request with Limits={PerFileBytes=1;AggregateBytes=2}} + Expect.equal (capture session).Model.FirstFailure (Some(ByteLimitExceeded "provenance")) "actual read enforces cap" + Expect.isEmpty (inspect session).Documents "over-limit bytes never become observations" + releaseControlled session) + testCase "digest failure precedes close uncertainty" (fun () -> + use fixture = new ControlledFixture(0,true) + let session=controlledSession fixture {fixture.Request with Selection={fixture.Request.Selection with ProvenanceSha256=String('0',64)}} + let observation=capture session + Expect.equal observation.Model.FirstFailure (Some(RawDigestMismatch "provenance")) "original digest failure preserved" + Expect.contains observation.Model.SecondaryFailures OwnedActivitiesPending "independent close failure preserved" + Expect.equal (release session) (Error OwnedActivitiesPending) "uncertainty retains owner") + testCase "changed original binding refuses revalidation" (fun () -> + use fixture = new ControlledFixture(0,false) + let session=controlledSession fixture fixture.Request + Expect.equal (capture session).Model.Phase Ready "ready before drift" + fixture.ChangeBinding() + Expect.equal (revalidate session).Model.FirstFailure (Some(InputChanged "provenance")) "original-object comparison rejects drift" + releaseControlled session) + testCase "one-use acquisition cannot replace retained original evidence" (fun () -> + use fixture = new ControlledFixture(0,false) + let session=controlledSession fixture fixture.Request + let first=capture session + let second=capture session + Expect.equal second.Model.FirstFailure (Some CaptureAlreadyConsumed) "no second acquisition" + Expect.equal second.Documents first.Documents "same bounded original bytes" + releaseControlled session) + ] |> testSequenced diff --git a/tests/FS.GG.Governance.VerifyCommand.Tests/SurfaceDriftTests.fs b/tests/FS.GG.Governance.VerifyCommand.Tests/SurfaceDriftTests.fs index e4b4457c..443380e1 100644 --- a/tests/FS.GG.Governance.VerifyCommand.Tests/SurfaceDriftTests.fs +++ b/tests/FS.GG.Governance.VerifyCommand.Tests/SurfaceDriftTests.fs @@ -65,6 +65,19 @@ let tests = unexpected) } + test "capture operation injection and ownership facts remain internal" { + let exported=verifyCommand.GetExportedTypes() + let names=exported |> Array.choose(fun t -> Option.ofObj t.FullName) + for shape in ["Operations"; "Ownership"; "Native"; "OwnerState"] do + Expect.isFalse + (names |> Array.exists(fun name -> name.StartsWith("FS.GG.Governance.VerifyCommand.ProviderContextCapture+"+shape,System.StringComparison.Ordinal))) + ("capture implementation shape escaped: "+shape) + let captureModule=exported |> Array.find(fun t -> t.Name="ProviderContextCapture") + let publicMethods=captureModule.GetMethods(System.Reflection.BindingFlags.Public ||| System.Reflection.BindingFlags.Static) + for name in ["prepareControlled"; "ownership"] do + Expect.isFalse (publicMethods |> Array.exists(fun method -> method.Name=name)) ("test access escaped: "+name) + } + test "VerifyCommand references VerifyJson (not AuditJson) and no kernel/host/cli" { let referenced = verifyCommand.GetReferencedAssemblies()