feat(bridge): add the closed Bridge Contract v2 compiler and lease runtime - #72
Merged
Marc-André Moreau (mamoreau-devolutions) merged 28 commits intoAug 3, 2026
Conversation
Marc-André Moreau (mamoreau-devolutions)
force-pushed
the
copilot/bridge-contract-compiler-v2
branch
from
August 2, 2026 19:33
d3a86b7 to
cc4e181
Compare
Marc-André Moreau (mamoreau-devolutions)
force-pushed
the
copilot/bridge-contract-compiler-v2
branch
from
August 2, 2026 20:48
c7e02cc to
22a4202
Compare
Marc-André Moreau (mamoreau-devolutions)
requested a review
from a team
as a code owner
August 2, 2026 22:07
Marc-André Moreau (mamoreau-devolutions)
force-pushed
the
copilot/bridge-contract-compiler-v2
branch
2 times, most recently
from
August 2, 2026 23:52
ec39095 to
07b0a0b
Compare
Task 3.1. Adds a second, independent generator beside the v1 live-contract preview so a payload pack can offer ordinary PowerShell property and method syntax over a finite application object graph the application declared member by member. v1 keeps its exact behaviour: its attributes, diagnostics, wire format, and emitted surface are untouched, and a compilation declares one root family or the other. The compiler accepts an interface DAG of at most 64 object types and depth 8, property get/set, bounded methods, explicitly bounded indexers and collections, nullable values, closed enumerations, copied data contracts, typed error data, and one-way event ordinals. Events are generated ordinals, never CLR event accessors, so no delegate crosses the boundary. Everything else fails at generator compile time with an actionable MPWLC011-MPWLC024 diagnostic: object, dynamic, Type, PSObject, delegates, Task, generics other than IReadOnlyList<T> and Nullable<T>, ref/out/in, pointers, arrays other than byte[], credentials, [Flags] enumerations, unannotated reference types, cyclic and handle-carrying data contracts, cross-boundary interface inheritance, unbounded strings and collections, duplicate or zero ordinals, and objects unreachable from the root. Bounds are per position and never inherited. A member-level bound applies to the result and every bounded parameter carries its own [BridgeBound], so a member with several string parameters cannot silently share one cap. The generator computes each member's worst-case request and reply size with saturating arithmetic and fails the build when a declaration could produce a frame above 64 KiB, so an oversized frame is a compile error rather than a runtime check. Both modes emit one canonical descriptor byte sequence and its SHA-256 hash from the analysed contract before any mode-specific emission, plus static member tables resolved through generated switches. Payload mode emits the CLR wrappers and their inline typed codecs; Host mode emits typed handler, call-context, and authorizer interfaces. Generated code contains no reflection, IDispatch, dynamic binder, or JSON path, and the tests assert that. BridgeMutation.Staged is rejected at compile time: the staged-intent coordinator exposes no programmatic stage/validate/commit entry point, so accepting the declaration and silently not staging would be worse than refusing to compile it. The normative wire, descriptor, dispatcher, failure, lease, and closure rules are in docs/in-process-ffi.md. That section states plainly that the duplex broker channel is not reachable from a payload pack today and names the contract-pack API change it needs, rather than claiming delivery it cannot make. The public native ABI and the facade surface are unchanged; the new types live in the payload-injected contract sources, which are now packed and injected alongside the v1 ones. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The generator fixtures prove the emitted codec compiles; nothing yet proved the primitives it is built on actually round-trip or refuse the right things. Adds scalar, string, list, data, nested list-of-data, handle, and frame-header round-trips, plus the rejections that carry the safety claims: an over-cap write latches failure, an embedded NUL is refused, a value above the reader''s declared cap is refused rather than truncated, a handle of the wrong declared object type is refused, a truncated or wrong-tagged value is refused, an element past its container''s declared end is refused rather than reinterpreted, a version-1 or undeclared-kind frame is refused, a body length that disagrees with its buffer is refused, and a fifth nested container exceeds the depth cap. Each assertion was mutation-checked: flipping one expectation fails the run. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Task 3.2a. Adds the bounded lease runtime and the generated consumer dispatcher, so a payload wrapper now reaches a typed application handler and back through an admitted, authorized frame instead of through code the application writes itself. Admission is one atomic step. Resolving the lease and resolving the object handle happen together under the lease lock, so a call cannot pass the lease check, lose a race to closure, and then fail object resolution. An unknown, closed, or superseded lease and an unknown, released, forged, or cross-lease handle are rejected identically, so a caller cannot probe which handles exist. An admitted call holds its resolved handler reference by value, which is why closure never has to block on, interrupt, or wait for work already in flight. The application never invents an object identifier. A handler returns a child handler interface and the dispatcher registers it and allocates the identifier; an inbound handle is resolved back to its registered handler within its own lease before the handler sees it. That removes handle forgery from the application''s responsibility rather than documenting it as its duty. Getter, setter, and method are authorized separately, immediately before the handler runs, and outside the lease lock so an authorizer can never deadlock against a handler. Reply capacity is checked against the member''s compile-time maximum before dispatch, so a handler cannot mutate and then fail on a buffer the caller sized too small, which would make a retry duplicate the side effect. Lease closure is one idempotent Active-to-Closed transition. The payload''s CloseLease returns a status and first caller wins; consumer disposal returns nothing and is idempotent. Closure tombstones every handle and supersedes the generation in the same locked transition, so an escaped wrapper observes a deterministic revoked error and retains no application state. Lease identifiers are process-monotonic and object identifiers are monotonic within their lease, and neither is ever reused, so a released wrapper can never target a later object. The dispatcher has two entry points. Dispatch is transport-neutral and takes spans, so a later carrier can feed it without changing a line of generated logic. Invoke is COM-shaped and copies through managed buffers, so a consumer project never needs AllowUnsafeBlocks. A source generator cannot see another generator''s output, so the application supplies a small [GeneratedComClass] that forwards to the dispatcher; it holds no lease state, decodes nothing, and authorizes nothing, and the doc shows it in full. The round-trip fixtures compile and emit the Host and Payload halves as separate assemblies, wired only by the shared transport interface, and drive the payload side through its ordinary public properties and methods: reads, a collection, an enumeration, a data contract, a child handle, a setter, a nullable value, a bounded indexer, and release. They then assert the Task 3.2 checklist directly — a retained wrapper fails after lease close, a stale generation and forged, zero, and cross-lease handles never reach the authorizer, a denied setter leaves no mutation while its getter stays independently authorized, unknown ordinals and a member aimed at the wrong object type are refused without mutation, a mismatched descriptor hash reports ContractMismatch, and sixty-four create/invoke/release cycles under collection remain stable. The public native ABI and the facade surface are unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Records the concrete shape of the approved contract-pack ABI extension before
implementing it, and the consequence that follows from routing v2 request/reply
through the duplex broker channel.
Two slots are appended after the current final slot of the pack API, which
already validates Size header-first and carries an abi_version. create_bridge_proxy
receives the payload-local request and post trampolines the broker context already
installs as $DpsBroker, so the payload bindings can install a generated wrapper as
a session variable and clear it on every completion path. It stays one required
table, all-or-nothing, with an older or smaller pack rejected loudly.
Event routing uses one reserved DBC kind with a { contractId, ordinal } body
prefix. Packing the ordinal into the kind word is recorded as considered and
rejected: it steals bits from an application-owned field and still collides when
two contracts share a channel.
The consequence is written down rather than discovered mid-implementation: once
the consumer owns a channel and a pump instead of projecting an object, v2 no
longer needs a per-contract COM transport interface, the MPWLC012 rule that
requires one, PowerShellLiveObject, or the hand-written forwarding class. v1 keeps
all of them unchanged. The dispatcher already exposes a transport-neutral span
core, so that substitution is a transport change rather than a regeneration of
dispatch logic.
Also notes that bind and unbind are the invocation-start and invocation-end
signals that do not exist today, so an invocation-scoped lease becomes possible
for the first time at that point.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Every fix below has a regression fixture that was mutation-checked: reverting the fix makes its fixture fail. 1. A [BridgeField] whose name was already lowercase was silently pinned to default in both directions. The constructor parameter was derived by lowercasing the first character, so for an already-lowercase field the parameter equalled the property and the emitted body was a self-assignment. Decode read the field correctly and discarded it; encode read the never-initialized property and always wrote zero. The descriptor and SHA-256 still advertised a live field, so both sides agreed byte for byte on a contract neither honoured. Constructor parameters are now assigned through this. and made unique against every declared field name, so two fields differing only by case no longer collide either. 2. Contract parameter names collided with generated locals, so a member such as bool Submit(string request) produced a raw CS0136 inside auto-generated code rather than one of the promised diagnostics. Every generated local emitted into a scope that also holds user parameter names now carries a __bridge prefix, and a declared member, parameter, or field name beginning with that reserved prefix is MPWLC014. 3. [return: BridgeBound] was declared on the attribute and documented as non-inherited, but GetReturnTypeAttributes was never called, so the return position silently fell back to the member-level bound. That is exactly the cross-position inheritance the attribute exists to prevent. A return-position declaration now wins. 4. Guid was the only allow-list type matched by name, and by namespace leaf, so Acme.System.Guid was accepted and emission then hardcoded global::System.Guid, silently substituting the author''s type. Guid, IReadOnlyList<T>, and Nullable<T> are now matched by symbol through the compilation. The inverse weakness is fixed too: the ban list rejected any type whose name merely contained "Credential", so a legitimate [BridgeData] ICredentialPolicy failed with a misleading reason. Those checks are now scoped to their real namespaces, and they only improve messages: the security property comes from the symbol-matched allow-list, which rejects an unlisted type by fall-through. 5. A null in a non-nullable byte[] position became an empty ReadOnlySpan and encoded as a well-formed empty value, while the sibling string path failed closed. Both now fail closed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The specification says an open after closure allocates a new lease whose identifier the process never reuses. The implementation tombstoned the lease but left it in the table, and the one-lease-per-broker rule then rejected every later open, so a dispatcher could serve exactly one lease for its whole lifetime. The round-trip fixtures did not catch it because each of them built a fresh dispatcher. Closing now removes the entry. Nothing escapes by doing so: lease identifiers are process-monotonic and generations are never reused, so a wrapper holding the old pair can never match the new lease. The new fixture asserts exactly that — reopen yields a different identifier and generation, the reopened lease works, and the wrapper from the first lease stays revoked across the reopen. It was mutation-checked. Also records the outcome of the adversarial review of the planned broker binding. The first design pass did not survive it, and the section now states what the move must solve rather than a design that is ready to build. The substantive corrections: a pump may not dispatch inline, because TryReceive releases the delivery handle before returning and one blocking handler would wedge the only pump; the move therefore buys application code off the pipeline thread and a bounded producer wait, not structural no-wait rules, and the dispatcher-rules text no longer claims otherwise. TryReplyError cannot carry the bridge status because Rust converts it to text and the payload sees only ManagedFailure, so status needs a normal reply envelope. Reply capacity is not conveyed over the broker, so the envelope must carry a required length. A routing prefix makes a maximally-sized contract frame exceed a maximally-configured channel body, so the compile-time budget must be derived from the final envelope. Reserving a kind conflicts with DBC declaring kind application-defined with no registration table, and multiple waiters can take each other''s frames. What survived: create_payload_proxy does forward an arbitrary non-null IUnknown and the payload already projects its own objects, so a payload-owned sink can reach a pack without a pack ABI break; discovery is the unsolved half, since the native descriptor carries no v2 marker, variable name, hash, or bounds. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The nullable-context rejection was load-bearing and untested. It is the only project-setting-dependent input anywhere near the canonical descriptor, so rejecting it is what makes Host/Payload hash parity true rather than probable; a future relaxation that infers nullability instead would let two sides diverge on a hash they both believe, silently. The fixture asserts the diagnostic and its reason, so that relaxation fails a test rather than only contradicting a comment. Both halves were mutation-checked independently: removing the rejection fails it, and keeping the rejection while dropping "hash parity" from the message also fails it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The first pass did not survive review. This folds in the corrections and names what is still open instead of papering over it. Status now travels in an 8-byte reply envelope on a successful broker reply, because reply_error collapses every bridge status into ManagedFailure; reply_error is reserved for pump and infrastructure failure, which is exactly the case where no bridge status exists. Reply capacity stops being a runtime discovery. The payload allocates from the member''s compile-time maximum, which the static member table already carries on both sides, so the check becomes a per-bind validation against the channel bound that PowerShell_SetBrokerContext now conveys. Between matched artifacts a buffer-too-small outcome cannot arise at all. Bind and unbind need no new frame kinds. A one-way frame may be coalesced and is unordered so neither can be one, but Open already binds and a request/reply Close replaces the COM CloseLease with the same idempotent first-caller-wins transition. Events stay genuinely one-way, which is what an event is. Discovery closes without a pack ABI break. The payload passes a payload-owned bidirectional sink through the existing create_payload_proxy; the pack declares its identity, hash, variable name, and frame bounds on the way to returning a proxy. The unambiguous "not supported" answer comes from a new direction bit on the descriptor rather than from a return code, so a v1 pack is never offered the sink and its generic E_FAIL can never be confused with a broken v2 pack. Adding a direction bit changes no layout and no slot, so the approved ABI extension turns out not to be needed. Routing is separated structurally: a channel carries bridge traffic or application traffic, never both. That leaves one open question rather than a silent assumption — the builder holds exactly one broker_channel, so "dedicated" requires either mutual exclusion between the bridge and raw $DpsBroker in an invocation, or a second channel slot. The first keeps the invariant structural and moves nothing in the ABI. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Both found by checking the design against a compiler and the code rather than by reasoning about it. The sink declared ReadOnlySpan<byte> parameters. That does not compile: a span parameter on a [GeneratedComInterface] method fails with SYSLIB1051 because the span marshaller does not support the unmanaged-to-managed direction a callback into the payload requires. Every buffer now crosses as a pointer and a length, matching the rest of the sink, and the corrected signature was compiled to confirm it. The capacity argument was also incomplete. It holds only because bridge traffic uses its own sink: FfiBrokerContext.Request allocates a reply buffer of the full channel bound on every call and gives the caller no way to size it, so a bridge riding $DpsBroker would allocate up to 64 KiB to read a short string. The sink sizes each reply from the member maximum the pack declared, which is both the correctness argument and the reason an ordinary property read costs a few hundred bytes rather than the channel''s whole body. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The second pass was reviewed and did not fully survive either. The corrections in it hold, but nine items remain open and several need a product decision rather than more design, so they are written down instead of discovered during implementation. The load-bearing ones: discovery is one-directional and cannot select among two v2 contracts in one pack, because the registry calls create_payload_proxy with only an IUnknown; Open/Close as bind/unbind is an invocation-scoped lease, which this task explicitly defers; a timed-out Open allocates a lease that nothing rolls back; the capacity inequality double-counts headers and omits Open, Release, Close, and events entirely; transport failure has no payload semantics because only a completed frame carries a body; routing identifies a schema rather than a binding instance; and there is no host-side non-COM construction path at all, since the only existing abstraction requires a generated COM interface and exports an IUnknown. Also corrects a claim of my own. "Adding a direction bit changes no layout and no slot" was true but the implied conclusion was not: Rust independently rejects any direction outside 0x03, so the bit needs a validation change on both sides and a baseline update. That is still far smaller than appending function-pointer slots, so preferring it over the pack ABI extension stands; the framing does not. Records a better third option for the one-channel question than either I had: keep the single builder slot but give it an explicit role, so the payload deterministically installs bridge sinks or $DpsBroker and rejects mixed use. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
DispatchOpen allocated the lease before encoding its reply, and nothing undid that allocation if the encode or the reply header failed. The one-lease-per- broker rule then rejected every later open, so a single failure permanently bricked the dispatcher for the rest of its life. Found by an adversarial review of the planned broker binding, which raised it as a duplex-channel concern: a request can time out after delivery while the worker keeps going, so a late reply merely fails and the lease is stranded. It is not specific to that carrier — the same shape is reachable today through the public Dispatch surface, because a caller supplies the reply span and the capacity separately, so a capacity preflight can pass while the buffer is too small to hold the lease value. The allocation is now rolled back on both failure paths. The regression fixture drives exactly that reachable shape and then asserts a later open still succeeds, which is the observable consequence of the rollback rather than a restatement of the fix. It was mutation-checked. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
An invocation uses its channel for a generated bridge or for raw $DpsBroker, never both; two invocations are the supported way to do both. Verified the reframe before recording it: set_broker already rejects a second attach with Backpressure, so mutual exclusion is the behaviour the ABI enforces today and allowing both would have been the change rather than the restriction. The reason recorded is the one that matters, not the cost. Under this rule, for a bridge invocation, every application request the script can make goes through the generated, authorized, leased contract surface. Allow both and that becomes false: a surface with no authorization, no lease validation, and no staging sits beside one that has all three, and the realistic failure is an application raw handler doing what a staged member would have staged without the staging. It is also the reversible choice, since adding a second channel later is additive while restricting later would break consumers. Recorded in the facade docs as a consumer-visible product statement, not only in the FFI document. Also flags a decision that is easy to miss: reusing Open/Close as bind and unbind is only coherent if a lease lives for one invocation, because a proxy created per invocation must open per invocation. Endorsing that reuse and deferring invocation-scoped leases are the same decision made twice with opposite answers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Two design passes on one increment produced twelve findings and then nine. Two passes on the compiler converged, so the non-convergence is evidence the increment is too large rather than the review too harsh. Split into four pieces, each independently testable and each surviving a decision already made. Corrects one inversion in the reasoning that led here. The sink does not create the invocation signal that lease scoping needs: that signal already exists, because $DpsBroker is installed at invocation start and removed at cleanup. What is missing is a pack-reachable object to deliver it to, and the sink is that object. The distinction matters because it is what makes the first increment self-contained. The sink handshake is self-contained only with an explicit split inside it: discovery happens once when the live-object variable is set, and the transport is bound and unbound per invocation through the sink. Verified that this is possible — liveObjectVariables retains a pack proxy across invocations and reconciles after each one, so the two halves can genuinely have different lifetimes. Without the split, discovery would have to happen inside the invocation window, the proxy would be created per invocation, and lease scope would be decided by accident rather than on purpose. Lease scope then becomes a free choice made on its own evidence. The host-side construction API is recorded as new public facade surface needing its own design and review rather than riding along with a transport swap. The carrier move comes last, by which point it is closer to the swap it was originally scoped as. Records the agreed exit: if a third pass on the first increment also fails to converge, ship the compiler and the lease runtime on their own and revisit the carrier move as separate work. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The process-wide lease budget was a static counter incremented on open and decremented only by an explicit close, so a dispatcher dropped without disposal consumed a slot forever. Sixteen abandoned dispatchers and no bridge anywhere in the process could open a lease again. Reproduced before fixing: the failure is "a bounded bridge runtime table is full", permanently. The budget is now held as weak references to the tables owning a lease, swept when a new open contends for the bound, so a collected table releases its slot without a finalizer and without changing the intended limit. Sixteen *live* leases remains the bound; sixteen *abandoned* ones no longer end the process. Found by triaging the remaining review findings for live-defect content rather than treating them as design work, after two such findings had already turned out to be shipped bugs. This one came from the adjacent question: acquiring a resource before the operation that can fail, with unwind on only one path, is the same shape as the reply-capacity and open-rollback defects already fixed here. That shape now appears nowhere else in the lease runtime. The triage itself found no further live defects. Capacity arithmetic, routing identity, and discovery direction are all genuinely future: the COM path sizes its buffers correctly at the top of the range, the COM transport is itself the instance discriminator so two bindings cannot be confused, and v1 packs disambiguate contracts by QueryInterface so only the proposed shared-identity sink would break selection. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Bind and unbind are Open and Close, and an unbind arriving while a call is in flight is the same idempotent first-caller-wins transition as close. The sequence is now three increments, not four: sink handshake with invocation leases, then the construction API, then the carrier move. Records a stronger argument than the one that selected it. "A captured object should not outlive its invocation" presupposes the answer. The reason that does not: the transport is bound per invocation regardless, so a session-scoped lease would persist while being unusable outside an invocation, naming a lease that survives but cannot be called. Once the carrier binds per invocation, invocation scope is forced rather than chosen. Also keeps a distinction the merge could lose. Discovery and lifetime are one increment but not one mechanism: a pack proxy is created once when the live-object variable is set, and its lease is opened at each invocation bind and closed at unbind. Discovery is a QueryInterface and a declaration handshake over metadata that never changes, so repeating it per invocation would buy nothing, and liveObjectVariables already retains a proxy across invocations. Folding the increment together does not require folding the mechanisms together. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Applying the agreed boundary test to increment one answered no: a v2 contract needs a new PowerShellLiveObjectDirection value, and that is not a payload-and- pack change. PowerShellLiveObjectContract.cs is compiled into the facade, the enum is recorded in the public ABI baseline as facade:type and facade:field entries, and Rust independently rejects any direction outside 0x03. So the bit lands on its own — facade enum, both validation masks, an additive baseline update, and the contract-pack rejection fixtures — before any protocol work. It is mechanical, it is where a mistake is expensive and a review is cheap, and separating it keeps an ABI diff out of the protocol commit. With the bit already in place the sink handshake is genuinely payload-and-pack only. This was the leading indicator firing on its first use, before any design or code was written, which is what it was for. Both triggers are now recorded: scope leaking outside an increment boundary, which is objectively checkable, and a design pass failing to converge, which is the lagging one. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…times The split that makes the sink increment tractable — a pack proxy created once at variable-set time, its lease opened and closed per invocation — was checked independently rather than taken on two readings of the same code. It holds, and the check returned three constraints the implementation must respect. Retention is by exact reference: reconciliation matches a retained lease against live runspace variables with ReferenceEquals, so a script that reassigns, removes, wraps, or copies the value drops the proxy. A bridge proxy is no more durable than the variable holding it. Reconciliation is not transactional: it disposes as it walks and rebuilds afterwards, and reading a lease value can throw, so an exception part-way through can dispose earlier entries and propagate. A v2 proxy joining that dictionary must not be able to throw from that path. The invocation window has two escape hatches. Stop alone does not close it — removal waits for a later completion or disposal — and Cleanup sets its completed flag before doing any work, so a failure part-way through skips the rest permanently rather than retrying. Lease close must be idempotent and exception safe on its own terms rather than merely placed in that path and assumed to run. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The payload keeps a handle table whose entries are added on decode and removed only by an explicit Release. It was bounded, but transitively: the consumer''s own object table caps at 1024, so the payload could never exceed it. A bound enforced only by the peer is not a bound on this side. Every other number in this protocol is re-checked locally — frame lengths, declared caps, collection counts, handle object types — and this one was the exception. It now carries the same bound itself and fails deterministically rather than growing on a peer that misbehaves. Found by scanning for the second shape of acquisition without guaranteed release: not "acquire, fallible operation, no unwind", which is what the reply-capacity and open-rollback defects were, but "release requires an action that may never happen". That is the shape of the lease-slot leak, and it is more dangerous because it surfaces only in a long-lived process after ordinary use rather than in a failing test. The scan found no further leak. The generator-side collections are compile-time and short-lived, and the lease runtime''s tables are released by tombstoning at close, which now also happens when a table is collected. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The v2 broker binding needs the payload to know which registered contracts speak the v2 protocol, so it can offer a handshake to those and leave a v1 pack alone. Inferring that from a generic failure code would make "not supported" indistinguishable from "supported but broken", so it becomes a property of the descriptor instead. This is the ABI-adjacent part of the sink increment, landed on its own and ahead of any protocol work. A direction bit crosses the managed facade, the Rust validator, and the recorded ABI baseline at once, which is the part where a mistake is expensive and a review is cheap. Keeping it out of the protocol commit means that commit does not carry an ABI diff alongside it. The marker is declared with ConsumerToSession, never instead of it. A contract that set the marker alone would read as "not a consumer-to-session surface" to every existing direction check while claiming to speak a protocol that only runs over one, so the pairing is enforced rather than documented. Both validators enforce it independently: the managed contract type refuses to construct such a descriptor, and the Rust input validator rejects it before the managed registry is reached. Neither trusts the other to have checked. To be precise about what the pairing rule buys, since the fixture below proved it: a lone marker was already rejected downstream by the registry's ConsumerToSession requirement. The rule is defense in depth plus a specific, early diagnosis at the line that made the mistake, not a new barrier where none existed. Widening both validators is additive. 0x06 and 0x07 were previously rejected as unknown bits and are now accepted; nothing that was accepted before is rejected now. The ABI baseline gains exactly one line. The rejection fixture writes the raw descriptor bits rather than projecting them from the managed contract type, because the managed constructor refuses to build one -- and a pack is native memory the consumer does not control, so the fixture has to be able to declare what an untrusted pack can declare. Mutation-checked in three directions: disabling the managed pairing rule makes the fixture fail as "rejected for the wrong reason" rather than pass, disabling the Rust pairing clause makes 0x04 accepted, and reverting the Rust mask makes 0x06 rejected. Verified: cargo fmt/clippy/build/test (clippy warning identities unchanged), the five ignored payload-lifecycle tests, the bindings build and tests, the API baseline, both generator suites, all six contract-pack rejection fixtures through the NativeAOT sample, and the isolated SDK package consumer end to end through a NativeAOT publish. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Three corrections to the decomposition, all bookkeeping rather than design. The direction bit is increment one and it has landed, so it reads as done rather than planned, and the pairing rule is stated with the scope the mutation check actually established: a lone marker was already rejected downstream by the registry, so the rule is defense in depth plus an early, specific diagnosis, not a new barrier. Claiming more than that would be claiming a vulnerability was closed when none was open. Two open items are closed because they were decided. The Open/Close lifecycle is adopted rather than deferred -- endorsing the reuse and deferring the lifecycle were the same decision made twice with opposite answers -- and the one-channel question takes the explicit-role option, because mutual exclusion alone is an absence rather than an invariant: set_broker records only the channel and its handle, so nothing carries intent and nothing can decide. The role gives the payload an actual decider while keeping one slot. The guarantee that follows is stated per invocation rather than per session, since a session may run a bridge invocation and a raw-broker invocation back to back. With invocation-scoped leases the two scopes line up instead of the lease outliving the claim. Finally, a heading lost in an earlier edit is restored. Settling the one-channel question replaced the surrounding options text and deleted "What the move does and does not buy" with it, leaving its paragraph headless and its opening "It" with no antecedent. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A non-nullable handle result is an annotation, not a runtime guarantee. The application on the other side of a bridge member is ordinary code and can return null from a member the contract declares as never null, and nothing in the generated dispatcher is in a position to stop it. The lease table already guards this -- Register rejects a null handler and returns identifier zero, which the writer then refuses to encode -- so the frame is reported as malformed and the lease keeps working. That behaviour was untested, which is the part worth changing: the guard sits in a public wrapper while the logic that would fault is one call further in, so reading either half alone gives the wrong answer about what happens. The fixture asserts InvalidArgument specifically rather than merely "some failure", because a healthy lease rejecting a bad frame must not be confused with a lease that has ended, and it then checks the lease still serves other members and the same member recovers once the application is fixed. Mutation-checked: dropping the null guard from Register produces ArgumentNullException out of Dictionary.FindValue instead, which is exactly the failure the guard exists to prevent. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Two defects in live-object variable lifetime, both the same shape: shared state is mutated before every operation that can fail has run. SetLiveObjectVariable creates the lease, publishes the variable, then reconciles and records the lease in the table. Only the publish is covered by rollback. If reconciliation throws -- and it can, because it reads every retained lease's value and a pack proxy can make that throw -- the new lease is published to script but present in no table, so neither reconciliation nor session teardown will ever release it. The pack handle leaks for the life of the process and script keeps an owner-less object. Rollback now covers reconciliation and the insertion too, and it takes the variable back before releasing the lease, since releasing first would leave a script holding a freed proxy. It reclaims the name only if the name still holds this lease's value, so it cannot destroy a variable something else took over. Reconciliation itself disposed leases as it walked and cleared the table only afterwards, so a failure part-way through left already-disposed leases listed as live. That is worse than it sounds: the next reconciliation reads those same entries, throws on the first disposed one, and every subsequent variable operation fails the same way. One unreadable proxy permanently broke the session. It now partitions first, swaps the table, and releases afterwards, so a failure can no longer leave the table describing leases that are gone. A lease whose value cannot be read is treated as dropped rather than allowed to abort the walk -- it is unusable either way, and releasing it is the outcome that terminates. Disposal is no longer abandoned at the first failure. Releases are all attempted and the first exception is reported afterwards, because stopping early leaked every remaining lease -- one misbehaving pack could strand all the others. Not covered by a dedicated fixture, and worth saying plainly: the only realistic way to make a retained lease unreadable is a contract pack that returns the same proxy handle for two leases, so that releasing one frees the handle the other still names. That is a pack contract violation the registry does not currently detect, and building the fixture means adding an accepted-but-misbehaving pack. Reported rather than bundled here. Verified instead against the paths that already exercise this code: the five explicit payload lifecycle tests, and the NativeAOT sample's live-object variable scenarios end to end through the facade. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Twelve findings, then nine, now three -- and all three land inside the increment rather than outside it, so the leading trigger has not fired. The pass itself specifies increment two: interposition, the declaration state machine, requested identity, and invocation-scoped leases, with the carrier deliberately left on COM throughout. One correction is to my own earlier framing. "The payload can hand a sink over through the existing callback" is true in its conclusion and understates its mechanism: CreatePayloadProxy has exactly one inbound pointer, so the sink cannot merely be passed alongside. The payload must interpose an object that answers QueryInterface for both the sink and the contract and forwards the contract onward, which is a COM identity the payload does not own today. No pack-ABI change, but a good deal larger than "also pass the sink", and that mattered because the smaller reading made the increment look cheaper than it is. Three corrections from review, each verified against the code first. The requested descriptor hash cannot be exposed before declaration. The inbound descriptor carries size, directions, identity and version, and no hash; the hash exists only in generated pack code. Supplying it would mean adding a field to the pack ABI, which is the one thing this decomposition exists to avoid -- and it buys nothing, because Open already compares the hash and already rejects a mismatch with a status that names it. Identity only. Several proxies over one broker now share one lease instead of the second bind being refused. The same broker can be assigned to more than one variable and each assignment creates a distinct proxy, while the lease table deliberately serves one lease at a time because one consumer broker owns one. Refusing the second bind left the first Bound and the second unable to reach any state at all, which is a stuck handshake rather than a defined failure. Sharing is coherent exactly because leases are invocation-scoped: every proxy over one broker binds and unbinds within the same invocation, so first-wins close ends something they were all finished with. The recorded reconciliation constraint was stale -- it describes behaviour fixed in the preceding commit -- so it now states the narrower residual hazard instead of one that no longer exists. And the discarded-proxy case is defined, because discovery happens once and ordinary script can drop the object before the next invocation: assigning null, letting the variable lapse, or wrapping it so the reference check no longer sees it. Binding fails loudly with a named status and re-discovery is explicit. That is not a preference: the sample already probes the neighbouring case and records that an escaped wrapper reads as null after its lease is released, so the object looks present and empty rather than revoked. Reintroducing that one layer up would undo the thing the v2 lease runtime was built to fix. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The objection is a fair one -- every other member of that enum names a way objects flow, a protocol marker is not a direction, and the descriptor already carries a reserved word both sides validate as zero -- so it is recorded with its answer rather than left to be raised again. Three things decided it. The pairing rule survives the move. "A v2 contract is consumer-to-session" is a fact about the domain, not an artifact of packing two axes into one word: a separate kind field could still be declared alongside PayloadToConsumer, which is exactly as meaningless. The constraint becomes cross-field instead of cross-bit and still needs writing twice. That matters because removing the rule was the main thing the split was supposed to buy. Failing closed does not choose between them either. A pre-marker host rejects 0x06 as outside the known direction mask exactly as it rejects a non-zero reserved word, so both are already safe against an older peer. And the reserved word is the more expensive home, not the cheaper one. It is a recorded baseline field, so giving it meaning means renaming it -- a removal, and the first non-additive change to this ABI -- or leaving a field called Reserved that is not reserved. PowerShellLiveObjectContract has no path to it at all, since ToNative never writes it, so carrying a kind there needs a new enum type, a second constructor overload and a new property: about six baseline entries against one, plus a permanent overload-resolution burden, to relocate a constraint rather than delete it. The long-term argument is that protocol kinds accumulate and crowd the enum. That trajectory is one this architecture forbids: capabilities are added by extending the current version in place under lock-step builds, not by introducing a parallel protocol version. No accumulation, no crowding. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The largest piece of increment two turns out not to exist. One narrow question -- does the pack need the contract object directly, or can it reach it through the sink -- collapsed it, and the answer was already visible in how a pack consumes the pointer it is given. A pack does not receive a specific interface. It projects the inbound pointer with its own ComWrappers and pattern-matches against interfaces it knows. So the pointer can simply be the sink: the payload passes a payload-owned sink for a contract marked BridgeContract, the pack matches the sink interface it was generated against, asks it for the consumer's contract object, and wraps that exactly as it wraps a v1 one today. That removes the hard part. The earlier reading had the payload owning a COM identity that answers QueryInterface for an arbitrary interface identifier it does not know at compile time, and forwards it onward. Now neither side forwards anything: the payload exposes one fixed interface it owns, the pack consumes one fixed interface it was generated against. Nor is it new machinery -- the payload already exposes its own managed objects as COM interfaces when it exports a live-object probe, so this is an existing, exercised shape. One thing the collapse does not remove, and it was missing from the proposal: the handshake exchanges two interfaces rather than one. A payload-owned sink carries pack-to-payload traffic, but bind and unbind travel the other way, because the payload is what knows where an invocation begins and ends. All the payload holds after CreatePayloadProxy is an opaque handle it cannot call, so the pack has to hand back its own interface when it declares. Both are fixed and compile-time known on both sides, which is what keeps this small. The substitution point needs nothing new either. The registry's CreateLease already receives the contract descriptor alongside the pointer and is the last place both are in scope before the pack is called, so the branch is one decision at one site -- which is also a second confirmation that the marker belonged in the descriptor and belonged first. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Marc-André Moreau (mamoreau-devolutions)
force-pushed
the
copilot/bridge-contract-compiler-v2
branch
from
August 3, 2026 00:18
07b0a0b to
795c14b
Compare
Marc-André Moreau (mamoreau-devolutions)
changed the base branch from
copilot/nativeaot-bridge-strategy
to
master
August 3, 2026 01:07
Marc-André Moreau (mamoreau-devolutions)
merged commit Aug 3, 2026
2fcfcfa
into
master
14 checks passed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Delivers Story 3 — the closed generated Bridge Contract Compiler v2 on top of the Duplex Broker Channel (#71). Stacked on
copilot/nativeaot-bridge-strategy; none of its commits were touched.Task 3.1 — the compiler
A second attribute family and a second generator beside the untouched v1 one, because v1's generator is a shape-matched three-object demo and generalising it would change its emitted surface — legacy packages have to keep their existing behaviour.
PostEventordinals mapping to the broker'sPost, not CLReventadd/remove.object,dynamic,Type,PSObject, delegates,Task, generics,ref/out, cyclic DTOs, cross-boundary inheritance and runtime member names are all rejected at generator compile time with actionable diagnostics (MPWLC011–MPWLC024).Two decisions worth surfacing. The type allow-list is symbol-matched rather than name-matched, because name matching accepted
Acme.System.Guid; the ban list is message-only and the security comes from the allow-list falling through. And nullable annotations must be explicit, because the annotation feeds the descriptor hash and would otherwise make the hash depend on a project setting — a test asserts the diagnostic message mentions hash parity, so relaxing it later fails a test rather than a comment.Task 3.2a — the lease, authorization and dispatch runtime
(leaseId, generation, objectId, memberId)travels in every request and lease state plus authorization are revalidated immediately before each mutation. Getter, setter and method authorization are separate; permission metadata is input to an authorizer, never a substitute for one. Handles are tombstoned atomically at lease end, so an escaped payload wrapper gets a deterministic revoked error. Explicit release ordinals; bounded lease and object tables.Mutation.Stagedis a compile error (MPWLC023) untilPowerShellStagedIntentCoordinatorexposes a programmatic API — it has none today, so generating against it would have been generating against nothing.Defects found and fixed
Every one came from the same filter — a resource acquired before an operation that can fail, with no unwind, or its more dangerous sibling, a resource whose release depends on an action that may never happen. The second only shows up in long-lived processes, which is why it is the worse of the two.
In this PR's own code:
DispatchOpenallocated a lease before encoding a reply that could fail, with no rollback.In already-shipped code:
The v2 broker binding, decomposed
Retiring the COM carrier is follow-on work, specified here but deliberately not implemented. Two design passes produced twelve then nine findings without converging, which is the signal that an increment is too large rather than that a review is too harsh — so it was split, and each piece is independently testable and survives a decision already made.
Increment 1 — the direction bit — landed here.
PowerShellLiveObjectDirection.BridgeContract, paired withConsumerToSessionand enforced independently by the managed and Rust validators, an additive ABI baseline entry, and a raw-descriptor rejection fixture. Split out because it touches the facade and the recorded public ABI — it failed the payload-and-pack boundary test, which is exactly the pre-agreed trigger for re-splitting rather than pushing through.The pairing rule is defense in depth plus an early, specific diagnosis, not a closed hole: a lone marker was already rejected downstream by the registry. The fixture writes raw descriptor bits, because the managed contract type refuses to construct one and a pack is native memory the consumer does not control.
Increments 2–4 (sink handshake and invocation leases, the host-side construction API, the carrier move) are specified in
docs/in-process-ffi.md. The third design pass on increment 2 produced three findings, all resolvable inside its own boundary.Constraints honoured
The payload binding table remains one required V1 ABI with header-first size validation — no V2 tables, no optional-slot negotiation, no compatibility manifests. Host and payload contract builds are lock-step; no contract-layer minor compatibility lane. v1
[LiveContract]behaviour is unchanged.Verification
cargo fmt --check,clippy(warning identities unchanged fromorigin/master),cargo build/test --all-targets, the five explicit payload-lifecycle tests, the bindings build and tests, the API baseline, both generator suites, all six contract-pack rejection fixtures through the NativeAOT sample, and the isolated SDK package consumer end to end through a NativeAOT publish.Every fixture is mutation-checked — and checked for failing for the right reason. One early version of the budget-leak test failed at the correct live bound rather than at the leak, which would have "confirmed" a fix that did nothing.