Skip to content

feat(bridge): add the closed Bridge Contract v2 compiler and lease runtime - #72

Merged
Marc-André Moreau (mamoreau-devolutions) merged 28 commits into
masterfrom
copilot/bridge-contract-compiler-v2
Aug 3, 2026
Merged

feat(bridge): add the closed Bridge Contract v2 compiler and lease runtime#72
Marc-André Moreau (mamoreau-devolutions) merged 28 commits into
masterfrom
copilot/bridge-contract-compiler-v2

Conversation

@mamoreau-devolutions

@mamoreau-devolutions Marc-André Moreau (mamoreau-devolutions) commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

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.

  • Interface DAGs up to 64 object types and depth 8; property get/set; bounded methods; explicitly bounded indexers and collections; nullable, enum and DTO references; typed error DTOs.
  • Events are generated one-way PostEvent ordinals mapping to the broker's Post, not CLR event add/remove.
  • A finite recursive tag set with caps per string, collection, DTO and frame. The frame budget is a compile-time check with saturating arithmetic, so an oversized product is a build error rather than a runtime surprise.
  • 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 (MPWLC011MPWLC024).
  • Emits payload CLR wrappers, consumer handler interfaces, canonical descriptor bytes, a SHA-256 descriptor hash, static member tables and typed codecs — no reflection, no IDispatch, no dynamic binder, no JSON.

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.Staged is a compile error (MPWLC023) until PowerShellStagedIntentCoordinator exposes 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:

  • A closed lease was never removed from the table, so a dispatcher served exactly one lease for life.
  • DispatchOpen allocated a lease before encoding a reply that could fail, with no rollback.
  • The process-wide lease budget leaked permanently on a dropped dispatcher — sixteen abandoned dispatchers would disable every bridge in the process, triggered by nothing more exotic than forgetting to dispose. Fixed by weak-reference sweeping, which preserves the intended bound of 16 live leases instead of raising a limit.
  • The payload handle table was bounded only transitively by its peer.
  • Five further defects found in independent review, plus three emitter defects caught only because the generator tests compile the generated code.

In already-shipped code:

  • A failed live-object publish leaked its lease. Reconciliation and the table insertion sat outside the rollback, so a throw left the proxy reachable from script and owned by nobody — released by neither reconciliation nor session teardown.
  • Reconciliation disposed as it walked and cleared the table afterwards, so a failure part-way left disposed leases listed as live. The next reconciliation then threw on the same entry, and every subsequent variable operation failed the same way — one unreadable proxy permanently broke the session.

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 with ConsumerToSession and 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 from origin/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.

@mamoreau-devolutions Marc-André Moreau (mamoreau-devolutions) changed the title feat(bridge): add the closed Bridge Contract v2 compiler feat(bridge): add the Bridge Contract v2 compiler and lease runtime Aug 2, 2026
@mamoreau-devolutions Marc-André Moreau (mamoreau-devolutions) changed the title feat(bridge): add the Bridge Contract v2 compiler and lease runtime feat(bridge): add the closed Bridge Contract v2 compiler and lease runtime Aug 2, 2026
@mamoreau-devolutions
Marc-André Moreau (mamoreau-devolutions) force-pushed the copilot/bridge-contract-compiler-v2 branch 2 times, most recently from ec39095 to 07b0a0b Compare August 2, 2026 23:52
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>
@mamoreau-devolutions
Marc-André Moreau (mamoreau-devolutions) changed the base branch from copilot/nativeaot-bridge-strategy to master August 3, 2026 01:07
@mamoreau-devolutions
Marc-André Moreau (mamoreau-devolutions) merged commit 2fcfcfa into master Aug 3, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant