Skip to content

feat(hull-dotnet): port the five built membrane faces and the resolution pipeline - #3655

Merged
ctwoodwa merged 6 commits into
mainfrom
feat/3652-hull-dotnet
Aug 5, 2026
Merged

feat(hull-dotnet): port the five built membrane faces and the resolution pipeline#3655
ctwoodwa merged 6 commits into
mainfrom
feat/3652-hull-dotnet

Conversation

@ctwoodwa

@ctwoodwa ctwoodwa commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

In plain terms: the .NET twin of the hull capability membrane - the five built faces plus the resolution pipeline, with the round-trip suite ported.

Why it matters: W3 of the .NET edition (ADR 0167 D3); the parity pair that keeps the .NET shell able to compose capabilities.

Done when: merged after code review. Round-trip suite 5/5 green on net11.0 (verified with the pinned preview SDK); local-subprocess arm fails closed until a sandbox is wired.

Refs: #3652

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added the Shipyard Hull .NET package with capability contracts, runtime connections, negotiation, health checks, invocation, cancellation, logging, and error handling.
    • Added in-process runtime support and a reference image runtime for testing capability workflows.
    • Added pack resolution, dependency validation, composition projection, entitlement checks, hardware filtering, licensing, and configuration selection.
    • Added the HullShell orchestration API for connecting runtimes, resolving providers, probing health, and invoking capabilities.
  • Documentation
    • Added package documentation describing the .NET Hull implementation and runtime behavior.
  • Tests
    • Added end-to-end coverage for invocation, negotiation, health reporting, failures, logging, and sandbox fail-closed behavior.

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@ctwoodwa, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 7 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f6f45e9f-a99b-4845-8404-7464918f3891

📥 Commits

Reviewing files that changed from the base of the PR and between 032e2a9 and 6c5cfb2.

📒 Files selected for processing (11)
  • _shared/engineering/code-review-policy.yaml
  • packages/hull-dotnet/Membrane/Announce.cs
  • packages/hull-dotnet/Membrane/Invoke.cs
  • packages/hull-dotnet/Membrane/Negotiate.cs
  • packages/hull-dotnet/Membrane/Observe.cs
  • packages/hull-dotnet/Membrane/RuntimeConnection.cs
  • packages/hull-dotnet/README.md
  • packages/hull-dotnet/Resolution/PackResolver.cs
  • packages/hull-dotnet/Shell/HullShell.cs
  • packages/hull-dotnet/tests/ParityReviewTests.cs
  • packages/hull-dotnet/tests/RoundTripTests.cs
📝 Walkthrough

Walkthrough

Added the Hull.NET package with capability contracts, membrane operations, runtime transports, resolution logic, shell orchestration, project wiring, documentation, and end-to-end tests. Local subprocess transport fails closed until sandbox support is available.

Changes

Hull.NET implementation

Layer / File(s) Summary
Contracts and runtime addressing
packages/hull-dotnet/Contracts/CapabilityContracts.cs, packages/hull-dotnet/Membrane/Address.cs
Added public wire-model records, capability identifiers, runtime address types, and transport-tier utilities.
Runtime hosts and transports
packages/hull-dotnet/Membrane/Transport.cs, packages/hull-dotnet/Runtime/RuntimeHost.cs, packages/hull-dotnet/Runtime/ReferenceImageRuntime.cs
Added runtime transport and host contracts, in-process forwarding, fail-closed local-subprocess behavior, and a reference image runtime.
Membrane lifecycle and invocation
packages/hull-dotnet/Membrane/Announce.cs, packages/hull-dotnet/Membrane/Negotiate.cs, packages/hull-dotnet/Membrane/Observe.cs, packages/hull-dotnet/Membrane/Invoke.cs, packages/hull-dotnet/Membrane/RuntimeConnection.cs
Added manifest indexing, contract negotiation, health probing, logging and redaction, invocation normalization, cancellation, and connection lifecycle operations.
Pack and provider resolution
packages/hull-dotnet/Resolution/Composition.cs, packages/hull-dotnet/Resolution/PackResolver.cs, packages/hull-dotnet/Resolution/Pipeline.cs
Added composition lookup, pack dependency and conflict validation, provenance tracking, tenant projection, and provider selection checks.
Shell integration and validation
packages/hull-dotnet/Shell/HullShell.cs, packages/hull-dotnet/tests/*, packages/hull-dotnet/*.csproj, Shipyard.slnx, packages/hull-dotnet/README.md
Added shell orchestration, project wiring, documentation, and round-trip tests for invocation, health, failures, capability rejection, and subprocess fail-closed behavior.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

Possibly related PRs

Suggested labels: needs-deep-review

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description states the purpose and test outcome but omits the required ICM Stage, Affected Packages, and Checklist sections. Add the template sections and complete the applicable ICM, package, build, test, documentation, and API checklist items.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the .NET membrane and resolution pipeline port.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/3652-hull-dotnet

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

thought (non-blocking):

Accessibility audit (advisory)

The sharded axe audit is report-only while the baseline and runtime budget mature.
Unknown moderate-or-higher findings must be resolved or added to the reviewed
accessibility baseline.

Shard Result Findings
1/4 pass 0 axe finding(s), 1 coverage warning(s)
2/4 pass 0 axe finding(s), 1 coverage warning(s)
3/4 pass 0 axe finding(s), 1 coverage warning(s)
4/4 pass No moderate-or-higher findings
Shard 1 report
[shipyard-syncstate-indicator] skipping directionalIcons enforcement: component has not opted in via [data-shipyard-direction="<state>"] sentinel.
Shard 2 report
[shipyard-button] skipping keyboardMap enforcement: component has not opted in via data-shipyard-keyboard-map="enabled".
Shard 3 report
[shipyard-dialog] skipping keyboardMap enforcement: component has not opted in via data-shipyard-keyboard-map="enabled".

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

🧹 Nitpick comments (16)
packages/hull-dotnet/Runtime/RuntimeHost.cs (1)

6-13: 🚀 Performance & Scalability | 🔵 Trivial

thought: IRuntimeHost is synchronous while IRuntimeTransport is asynchronous. For the reference stub this seam is harmless, and it keeps in-process hosts simple.

The seam does constrain future hosts. A real image runtime that performs GPU or disk work inside Invoke will block the caller's thread for the whole operation, because InProcessTransport wraps the synchronous call in Task.FromResult. Consider whether IRuntimeHost should expose async members before the first non-stub host implements it. Changing this interface later is a breaking change for every host.

No change requested in this PR.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/hull-dotnet/Runtime/RuntimeHost.cs` around lines 6 - 13, No change
is requested; leave the synchronous IRuntimeHost interface and its current
transport seam unchanged.
packages/hull-dotnet/Membrane/Address.cs (1)

13-18: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

suggestion: Adr0061TransportTiers pins an ADR number into a public type name. If ADR 0061 is later superseded or amended, the type name becomes misleading, and renaming a public type is a breaking change for consumers.

Name the type for the concept and record the ADR reference in a doc comment instead.

♻️ Proposed change
+/// Transport tiers defined by ADR 0061.
-public static class Adr0061TransportTiers
+public static class TransportTiers
 {
     public const string LocalNetwork = "LocalNetwork";
     public const string MeshVpn = "MeshVpn";
     public const string ManagedRelay = "ManagedRelay";
 }

The ADR number itself is fine: 0061 is not one of the reserved numbers, and no locked vocabulary appears here.

As per path instructions, "avoid introducing reserved ADR numbers 0067, 0068, or 0076" and "Any future ADR amendment must use the next gap-free A-number."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/hull-dotnet/Membrane/Address.cs` around lines 13 - 18, Rename the
public type Adr0061TransportTiers to a concept-based name that does not embed
the ADR number, preserving the existing LocalNetwork, MeshVpn, and ManagedRelay
constants. Add a documentation comment on the renamed type recording ADR 0061 as
its reference.

Source: Path instructions

packages/hull-dotnet/Membrane/Transport.cs (1)

24-28: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

todo: BaseUri accepts any Uri, including a non-loopback http:// host. No code path dereferences it today, because all five operations fail closed, so there is no current exposure.

When the sandbox transport is wired, validate baseUri at construction. A local-subprocess arm should only accept a loopback host. That check is much cheaper to add now than to retrofit after the first request-issuing code path exists.

🛡️ Proposed guard
 public sealed class LocalSubprocessTransport(string runtimeId, Uri baseUri) : IRuntimeTransport
 {
     public string RuntimeId { get; } = runtimeId;
     public string Mode => AddressModes.LocalSubprocess;
-    public Uri BaseUri { get; } = baseUri;
+    public Uri BaseUri { get; } = IsLoopback(baseUri)
+        ? baseUri
+        : throw new ArgumentException(
+            "local-subprocess requires a loopback base URI", nameof(baseUri));
+
+    private static bool IsLoopback(Uri uri) => uri.IsLoopback;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/hull-dotnet/Membrane/Transport.cs` around lines 24 - 28, Update the
LocalSubprocessTransport constructor to validate baseUri before assignment,
accepting only URIs whose host is loopback; reject non-loopback hosts, including
non-loopback http:// URIs, while preserving the existing RuntimeId, Mode, and
BaseUri behavior for valid inputs.
packages/hull-dotnet/Contracts/CapabilityContracts.cs (2)

43-58: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

nit: Four capability ids are inline string literals here, but image and bank-import are constants in CapabilityIds. Add the remaining ids as constants so every capability id has one definition site.

♻️ Proposed change
 public static class CapabilityIds
 {
     public const string Image = "image";
     public const string BankImport = "bank-import";
+    public const string Tts = "tts";
+    public const string Embeddings = "embeddings";
+    public const string Rerank = "rerank";
+    public const string Generate = "generate";
 }
 public sealed record TtsCore(string Text, string? Voice, string Format, double Timeout)
-    : CapabilityCore("tts");
+    : CapabilityCore(CapabilityIds.Tts);
 public sealed record EmbeddingsCore(IReadOnlyList<string> Inputs, int Dimension, double Timeout)
-    : CapabilityCore("embeddings");
+    : CapabilityCore(CapabilityIds.Embeddings);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/hull-dotnet/Contracts/CapabilityContracts.cs` around lines 43 - 58,
Add constants for the inline capability IDs used by TtsCore, EmbeddingsCore,
RerankCore, and GenerateCore in CapabilityIds, then update each corresponding
constructor to reference those constants instead of string literals. Preserve
the existing ID values and ensure all capability IDs have a single definition
site.

193-200: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

suggestion [dotnet]: document SelectionReason and Reason in ResolutionResult

Both fields exist in the canonical TypeScript contracts. Add a short C# comment on this record to state that SelectionReason is the closed selection-class driving SpeedHint, while Reason is the human display text.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/hull-dotnet/Contracts/CapabilityContracts.cs` around lines 193 -
200, Add a concise XML documentation comment to the ResolutionResult record
clarifying that SelectionReason is the closed selection class that drives
SpeedHint, while Reason contains human-readable display text; leave the record
fields and behavior unchanged.
packages/hull-dotnet/Runtime/ReferenceImageRuntime.cs (1)

44-59: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

nit: Two small things in Invoke.

core.Format is interpolated into the MIME type at line 59 with no validation. A caller supplying an arbitrary Format produces a malformed Mime value in the result envelope. There is no injection sink in this cohort, because the artifact stays an in-memory stub:// record. It is still worth validating against a short allowlist so consumers can trust the field. The Uri.EscapeDataString on core.Prompt in the same line is the right call.

Separately, lines 44 and 51 use Array.Empty<Artifact>() while the rest of the file uses collection expressions.

♻️ Proposed change
-                jobId, "failed", 0, Array.Empty<Artifact>(), new Usage("image", 0, null, "local"),
+                jobId, "failed", 0, [], new Usage("image", 0, null, "local"),
+        if (core.Format is not ("png" or "jpeg" or "webp"))
+        {
+            return new CapabilityResult(
+                jobId, "failed", 0, [], new Usage("call", 0, null, "local"),
+                new CapabilityError("input", false, "input.unsupported_format", $"reference runtime does not render '{core.Format}'", null));
+        }
+
         return new CapabilityResult(
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/hull-dotnet/Runtime/ReferenceImageRuntime.cs` around lines 44 - 59,
Update Invoke so core.Format is validated against a short supported-format
allowlist before constructing the ImageArtifact MIME value, returning the
existing input-failure result for unsupported formats; preserve the
Uri.EscapeDataString handling for core.Prompt. Also replace the two
Array.Empty&lt;Artifact&gt; usages in Invoke with the file’s
collection-expression style.
Shipyard.slnx (1)

641-644: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

nit: the solution folder name keeps the packages/ prefix that every sibling drops.

All other folders in this file use the short form, for example /contracts/, /ui-core/, and /bridge-subscription/. Use /hull/ or /hull-dotnet/ for consistency. The project paths themselves are correct.

♻️ Proposed rename
-  <Folder Name="/packages/hull-dotnet/">
+  <Folder Name="/hull-dotnet/">
     <Project Path="packages/hull-dotnet/Shipyard.Hull.csproj" />
     <Project Path="packages/hull-dotnet/tests/Shipyard.Hull.Tests.csproj" />
   </Folder>

As per path instructions, "preserve established naming conventions".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Shipyard.slnx` around lines 641 - 644, Rename the solution folder entry from
“/packages/hull-dotnet/” to the consistent short form “/hull-dotnet/”, while
preserving both existing project paths unchanged.

Source: Path instructions

packages/hull-dotnet/Membrane/Negotiate.cs (1)

37-43: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

nit: SchemaMajorMatches duplicates IsContractVersionCompatible.

Both methods parse two versions and compare majors. Keep one rule and let the schema path call it. This also keeps the two comparisons from drifting apart later.

♻️ Proposed consolidation
-    private static bool SchemaMajorMatches(string shell, string runtime)
-    {
-        var shellVersion = ParseSemver(shell);
-        var runtimeVersion = ParseSemver(runtime);
-        return shellVersion is not null && runtimeVersion is not null
-            && shellVersion.Value.Major == runtimeVersion.Value.Major;
-    }
+    private static bool SchemaMajorMatches(string shell, string runtime) =>
+        IsContractVersionCompatible(shell, runtime);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/hull-dotnet/Membrane/Negotiate.cs` around lines 37 - 43, Remove the
duplicate major-version comparison in SchemaMajorMatches and route the schema
compatibility check through the existing IsContractVersionCompatible method.
Preserve the current behavior for valid and invalid semantic versions while
ensuring both paths use the single shared compatibility rule.
packages/hull-dotnet/tests/RoundTripTests.cs (1)

114-122: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

nit: Line 121 asserts nothing.

_ = result.Error; reads like a check inside an assertion helper but only discards the value. Either drop the line, or assert the intended invariant, for example that Error is non-null when Status is "failed".

♻️ Proposed change
         Assert.NotNull(result.Usage);
-        _ = result.Error;
+        if (result.Status == "failed")
+        {
+            Assert.NotNull(result.Error);
+        }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/hull-dotnet/tests/RoundTripTests.cs` around lines 114 - 122, Update
AssertUniformEnvelope so it no longer includes the ineffective `_ =
result.Error` expression; either remove it or replace it with an assertion that
Error is non-null when result.Status indicates failure.
packages/hull-dotnet/Membrane/Invoke.cs (1)

55-64: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

nit: the second predicate on Line 59 is redundant.

status equals "failed" only when native.Status is "failed" or invalid. So status == "failed" && native.Status != "failed" already means the native status was invalid. The trailing native.Status is not (...) list adds no branch and must be kept in sync with Line 55 by hand.

♻️ Proposed simplification
-        if (status == "failed" && error is null && native.Status is not ("failed" or "accepted" or "running" or "succeeded" or "partial"))
+        if (status == "failed" && error is null && native.Status != "failed")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/hull-dotnet/Membrane/Invoke.cs` around lines 55 - 64, In the
error-assignment condition within Invoke, remove the redundant native.Status
pattern check and rely on status being "failed" while native.Status is not
"failed" to identify invalid statuses. Preserve the existing null-error guard
and CapabilityError behavior.
packages/hull-dotnet/Shell/HullShell.cs (1)

106-113: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

nit: "tts" is a bare literal while the other two entries use CapabilityIds.

Line 110 and Line 119 both repeat "tts". The image and bank-import entries use constants. Add CapabilityIds.Tts and use it in both places.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/hull-dotnet/Shell/HullShell.cs` around lines 106 - 113, Define the
missing CapabilityIds.Tts constant and replace both repeated bare "tts" literals
in the composition entries, including CarrierComposition and the other
occurrence, with that constant while preserving the existing entry values.
packages/hull-dotnet/Resolution/PackResolver.cs (4)

274-278: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

nit (non-blocking): parse version parts with invariant culture and no sign.

int.TryParse(string, out int) uses NumberStyles.Integer and the current culture. It accepts a leading sign and surrounding whitespace. "1.-2.3" parses to VersionValue(1, -2, 3) instead of being rejected. The result also depends on the ambient culture, which weakens clean-checkout reproducibility.

Use NumberStyles.None with CultureInfo.InvariantCulture.

♻️ Proposed change
+using System.Globalization;
         var parts = value.Trim().Split('.');
         if (parts.Length == 3
-            && int.TryParse(parts[0], out var major)
-            && int.TryParse(parts[1], out var minor)
-            && int.TryParse(parts[2], out var patch))
+            && int.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out var major)
+            && int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var minor)
+            && int.TryParse(parts[2], NumberStyles.None, CultureInfo.InvariantCulture, out var patch))

As per path instructions: "verification must be clean-checkout reproducible (A7)".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/hull-dotnet/Resolution/PackResolver.cs` around lines 274 - 278,
Update the version-part parsing in the visible resolver logic to call the
overloads of int.TryParse for parts[0], parts[1], and parts[2] with
NumberStyles.None and CultureInfo.InvariantCulture, preserving the existing
three-part validation and VersionValue construction.

Source: Path instructions


60-67: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

suggestion (non-blocking): use CompositionError for an unknown scope tier.

Every other catalog-validation failure in this file throws CompositionError with a machine-readable Reason. Line 66 throws ArgumentOutOfRangeException instead. A caller that catches CompositionError to report catalog problems will miss a malformed ScopeTier. PackSpecificity is public and is also reached from ComputeClosure and MergeDefaults, so the inconsistency is observable.

♻️ Proposed change
-        _ => throw new ArgumentOutOfRangeException(nameof(pack), pack.ScopeTier, "Unknown scope tier")
+        _ => throw Error("unknown-scope-tier", $"pack '{pack.Name}' declares unknown scope tier '{pack.ScopeTier}'", [pack.Name], pack.ScopeTier)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/hull-dotnet/Resolution/PackResolver.cs` around lines 60 - 67, Update
PackSpecificity’s unknown ScopeTier branch to throw CompositionError instead of
ArgumentOutOfRangeException, using the established machine-readable Reason
pattern used by other catalog-validation failures in the file. Preserve the
existing specificity values and known-tier behavior.

245-247: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

nit (non-blocking): the nested ternary calls TryGetValue twice and reassigns previous.

Line 245 and Line 247 both call owners.TryGetValue(key, out previous). The second call reassigns the same out variable inside a ternary branch. The intent is clear only after tracing all three branches. A single lookup makes the three cases explicit.

♻️ Proposed change
-        var overrode = owners.TryGetValue(key, out var previous) && previous.Pack != packName
-            ? [.. previous.Overrode, previous.Pack]
-            : owners.TryGetValue(key, out previous) ? previous.Overrode : new List<string>();
+        List<string> overrode;
+        if (!owners.TryGetValue(key, out var previous)) overrode = [];
+        else if (previous.Pack != packName) overrode = [.. previous.Overrode, previous.Pack];
+        else overrode = previous.Overrode;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/hull-dotnet/Resolution/PackResolver.cs` around lines 245 - 247,
Update the owner lookup around the overrode calculation to call
owners.TryGetValue only once, store its result, and express the existing
changed-pack, unchanged-pack, and missing-owner cases explicitly. Preserve the
current overrode values and appended previous.Pack behavior while removing the
nested ternary and reassignment of previous.

244-244: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

suggestion (non-blocking): make the merged collection order deterministic.

entries is a Dictionary<string, JsonElement>. Its enumeration order is not a documented guarantee. Line 244 serializes entries.Values directly into ResolvedDefaults, so the element order of every merged collection default depends on an unspecified detail.

The verification policy requires clean-checkout reproducible results. Order the entries explicitly before serializing.

♻️ Proposed change
-        merged[key] = JsonSerializer.SerializeToElement(entries.Values.ToArray());
+        merged[key] = JsonSerializer.SerializeToElement(
+            entries.OrderBy(pair => pair.Key, StringComparer.Ordinal).Select(pair => pair.Value).ToArray());

As per path instructions: "verification must be clean-checkout reproducible (A7)".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/hull-dotnet/Resolution/PackResolver.cs` at line 244, Update the
merged collection serialization at the assignment to merged[key] so entries are
ordered explicitly before converting their values to an array and serializing.
Use a stable ordering based on each dictionary entry’s key, preserving the
existing JsonSerializer.SerializeToElement flow and resulting values.

Source: Path instructions

packages/hull-dotnet/Resolution/Pipeline.cs (1)

62-73: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

suggestion (non-blocking): three distinct failures collapse into "locked-entitlement".

Line 65 reports "locked-entitlement" when the provider-license gate blocks every provider. Line 72 reports the same state when the configuration resolver chooses nothing. Line 49 reports it for an unentitled non-core capability.

HullShell.InvokeAsync derives the error code from ResolutionState, so all three produce membrane.resolution_locked_entitlement. A caller cannot tell a licensing block from a configuration gap. Introduce distinct states, for example "locked-license" and "no-configured-provider".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/hull-dotnet/Resolution/Pipeline.cs` around lines 62 - 73, Update the
resolution states returned by the provider-license gate and
configuration-selection branches in the pipeline method containing survivors and
choice so they no longer use "locked-entitlement": return distinct states such
as "locked-license" for an empty survivors set and "no-configured-provider" when
choice is null. Preserve the existing entitlement state for the unentitled
non-core capability path and ensure HullShell.InvokeAsync maps the new
ResolutionState values to distinct error codes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/hull-dotnet/Membrane/Announce.cs`:
- Around line 10-13: Update the capability indexing in Announce.Index so
duplicate CapabilityId values cannot let ToDictionary throw during ConnectAsync.
Either deterministically retain one capability entry or reject the manifest
through the existing typed membrane-error/envelope path, ensuring malformed
announcements produce a structured result rather than an unhandled
ArgumentException.

In `@packages/hull-dotnet/Membrane/Invoke.cs`:
- Around line 33-48: Update the catch logic in InvokeCapabilityAsync to detect
OperationCanceledException caused by the caller’s cancellationToken and rethrow
it before transport-fault logging and CapabilityResult construction. Preserve
the existing transport-fault handling for other exceptions and cancellation not
requested by the caller.

In `@packages/hull-dotnet/Resolution/PackResolver.cs`:
- Around line 251-253: Update Satisfies to distinguish an unparseable pack
version from a valid version that fails the constraint: when TryParseVersion
cannot parse version, raise the established explicit version-parse/malformed
error instead of returning false. Preserve the existing false result for valid
versions that do not satisfy constraint, and keep constraint parse failures
handled as currently.
- Around line 168-193: Update the same-tier default resolution around the
foreach over pack.Defaults to track each key’s shape (keyed collection versus
scalar) independently of scalarContributors. Raise same-tier-conflict when
another pack uses the opposite shape, regardless of iteration order, while
preserving existing collection merging and scalar conflict behavior. Also
validate that any key declared in DefaultMergeKeys has an array value; otherwise
raise an explicit malformed-input error instead of falling back to scalar
handling.
- Around line 35-37: Update the composition-building flow around
capabilityProvenance and CompositionEntry to emit at most one entry per
CapabilityId, including when CheckTier1Collisions permits duplicate
non-domain-block realizations. Inspect the pack/provides contract for membership
metadata and stop forcing every resolved entry to "core" when "add-on" or "n-a"
is represented; align the result with HullShell.CarrierComposition and the
intended W3 behavior.
- Line 288: Update the JsonEqual method to compare JsonElement values with
JsonElement.DeepEquals rather than GetRawText string equality, ensuring
equivalent JSON formatting and property ordering are treated as equal before
same-tier conflicts are reported.

In `@packages/hull-dotnet/Resolution/Pipeline.cs`:
- Around line 100-101: Update NonResolving in Pipeline.cs to accept the failure
reason, tier, and speed hint from its callers instead of hard-coding
entitlement-gated, local, and moderate. Adjust all four call sites to pass
accurate values—use not-in-edition for the edition gate and unavailable-hardware
for hardware failures—while preserving each call site's descriptive explanation
text.
- Around line 125-130: Update the selection block in HullShell.Resolve to add
ProviderId as an explicit secondary ordering key after SpeedRank, ensuring
deterministic tie resolution. Also change the reason condition to check
pool.Length == 1 instead of survivors.Count == 1, while preserving the existing
fallback-floor and preferred-by-policy cases.

In `@packages/hull-dotnet/Runtime/ReferenceImageRuntime.cs`:
- Around line 11-17: Synchronize all mutable state in ReferenceImageRuntime,
especially _cancelledJobs updates performed by Cancel and reads exposed through
CancelledJobs, so concurrent InProcessTransport.CancelAsync calls and
enumeration are safe. Return a snapshot or otherwise prevent callers from
observing the live list, while preserving the existing API behavior. Because
this is a concurrency control-point change, flag it for independent review
rather than self-assertion.

In `@packages/hull-dotnet/Runtime/RuntimeHost.cs`:
- Around line 20-32: Update RuntimeHost’s five async methods—AnnounceAsync,
NegotiateOfferAsync, HealthAsync, InvokeAsync, and CancelAsync—to honor
cancellationToken before invoking the host and to return faulted tasks when host
operations throw instead of throwing synchronously. Add and reuse a private
generic helper for synchronous Func<T> operations, with equivalent handling for
the void Cancel operation.

In `@packages/hull-dotnet/Shell/HullShell.cs`:
- Around line 15-28: Replace the plain _connections Dictionary in HullShell with
a thread-safe ConcurrentDictionary, adding the required namespace import, while
preserving the existing ordinal key comparison and public Runtimes behavior.
Ensure ConnectAsync continues to publish connections through the concurrent
collection so concurrent ConnectAsync, reads, ProbeAsync, Resolve, and
InvokeAsync calls are safe.
- Around line 76-98: Update the invocation flow in HullShell’s resolution
handling to honor resolution.ChosenProviderId after
Resolve(request.CapabilityId) succeeds. Select the connection whose announced
providers include that exact chosen provider, or forward the chosen provider
through RuntimeConnectionLifecycle.InvokeAsync, and do not fall back to an
arbitrary compatible capability connection.

In `@packages/hull-dotnet/tests/RoundTripTests.cs`:
- Around line 87-103: Extend the round-trip test coverage around the existing
bank-import test to exercise the SEC-2 throwing path by omitting or invalidating
the idempotency key and asserting the fail-closed
IdempotencyKeyRequiredException behavior. Add focused tests for
Negotiate.Reconcile returning Compatible == false and RedactingLogSink redacting
sensitive values, using the existing test infrastructure. Mark these
control-point changes for independent review rather than relying on
self-assertion.

---

Nitpick comments:
In `@packages/hull-dotnet/Contracts/CapabilityContracts.cs`:
- Around line 43-58: Add constants for the inline capability IDs used by
TtsCore, EmbeddingsCore, RerankCore, and GenerateCore in CapabilityIds, then
update each corresponding constructor to reference those constants instead of
string literals. Preserve the existing ID values and ensure all capability IDs
have a single definition site.
- Around line 193-200: Add a concise XML documentation comment to the
ResolutionResult record clarifying that SelectionReason is the closed selection
class that drives SpeedHint, while Reason contains human-readable display text;
leave the record fields and behavior unchanged.

In `@packages/hull-dotnet/Membrane/Address.cs`:
- Around line 13-18: Rename the public type Adr0061TransportTiers to a
concept-based name that does not embed the ADR number, preserving the existing
LocalNetwork, MeshVpn, and ManagedRelay constants. Add a documentation comment
on the renamed type recording ADR 0061 as its reference.

In `@packages/hull-dotnet/Membrane/Invoke.cs`:
- Around line 55-64: In the error-assignment condition within Invoke, remove the
redundant native.Status pattern check and rely on status being "failed" while
native.Status is not "failed" to identify invalid statuses. Preserve the
existing null-error guard and CapabilityError behavior.

In `@packages/hull-dotnet/Membrane/Negotiate.cs`:
- Around line 37-43: Remove the duplicate major-version comparison in
SchemaMajorMatches and route the schema compatibility check through the existing
IsContractVersionCompatible method. Preserve the current behavior for valid and
invalid semantic versions while ensuring both paths use the single shared
compatibility rule.

In `@packages/hull-dotnet/Membrane/Transport.cs`:
- Around line 24-28: Update the LocalSubprocessTransport constructor to validate
baseUri before assignment, accepting only URIs whose host is loopback; reject
non-loopback hosts, including non-loopback http:// URIs, while preserving the
existing RuntimeId, Mode, and BaseUri behavior for valid inputs.

In `@packages/hull-dotnet/Resolution/PackResolver.cs`:
- Around line 274-278: Update the version-part parsing in the visible resolver
logic to call the overloads of int.TryParse for parts[0], parts[1], and parts[2]
with NumberStyles.None and CultureInfo.InvariantCulture, preserving the existing
three-part validation and VersionValue construction.
- Around line 60-67: Update PackSpecificity’s unknown ScopeTier branch to throw
CompositionError instead of ArgumentOutOfRangeException, using the established
machine-readable Reason pattern used by other catalog-validation failures in the
file. Preserve the existing specificity values and known-tier behavior.
- Around line 245-247: Update the owner lookup around the overrode calculation
to call owners.TryGetValue only once, store its result, and express the existing
changed-pack, unchanged-pack, and missing-owner cases explicitly. Preserve the
current overrode values and appended previous.Pack behavior while removing the
nested ternary and reassignment of previous.
- Line 244: Update the merged collection serialization at the assignment to
merged[key] so entries are ordered explicitly before converting their values to
an array and serializing. Use a stable ordering based on each dictionary entry’s
key, preserving the existing JsonSerializer.SerializeToElement flow and
resulting values.

In `@packages/hull-dotnet/Resolution/Pipeline.cs`:
- Around line 62-73: Update the resolution states returned by the
provider-license gate and configuration-selection branches in the pipeline
method containing survivors and choice so they no longer use
"locked-entitlement": return distinct states such as "locked-license" for an
empty survivors set and "no-configured-provider" when choice is null. Preserve
the existing entitlement state for the unentitled non-core capability path and
ensure HullShell.InvokeAsync maps the new ResolutionState values to distinct
error codes.

In `@packages/hull-dotnet/Runtime/ReferenceImageRuntime.cs`:
- Around line 44-59: Update Invoke so core.Format is validated against a short
supported-format allowlist before constructing the ImageArtifact MIME value,
returning the existing input-failure result for unsupported formats; preserve
the Uri.EscapeDataString handling for core.Prompt. Also replace the two
Array.Empty&lt;Artifact&gt; usages in Invoke with the file’s
collection-expression style.

In `@packages/hull-dotnet/Runtime/RuntimeHost.cs`:
- Around line 6-13: No change is requested; leave the synchronous IRuntimeHost
interface and its current transport seam unchanged.

In `@packages/hull-dotnet/Shell/HullShell.cs`:
- Around line 106-113: Define the missing CapabilityIds.Tts constant and replace
both repeated bare "tts" literals in the composition entries, including
CarrierComposition and the other occurrence, with that constant while preserving
the existing entry values.

In `@packages/hull-dotnet/tests/RoundTripTests.cs`:
- Around line 114-122: Update AssertUniformEnvelope so it no longer includes the
ineffective `_ = result.Error` expression; either remove it or replace it with
an assertion that Error is non-null when result.Status indicates failure.

In `@Shipyard.slnx`:
- Around line 641-644: Rename the solution folder entry from
“/packages/hull-dotnet/” to the consistent short form “/hull-dotnet/”, while
preserving both existing project paths unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ce1a3a6f-1cab-4d35-a03e-7d7412518d5c

📥 Commits

Reviewing files that changed from the base of the PR and between 131087f and 032e2a9.

📒 Files selected for processing (19)
  • Shipyard.slnx
  • packages/hull-dotnet/Contracts/CapabilityContracts.cs
  • packages/hull-dotnet/Membrane/Address.cs
  • packages/hull-dotnet/Membrane/Announce.cs
  • packages/hull-dotnet/Membrane/Invoke.cs
  • packages/hull-dotnet/Membrane/Negotiate.cs
  • packages/hull-dotnet/Membrane/Observe.cs
  • packages/hull-dotnet/Membrane/RuntimeConnection.cs
  • packages/hull-dotnet/Membrane/Transport.cs
  • packages/hull-dotnet/README.md
  • packages/hull-dotnet/Resolution/Composition.cs
  • packages/hull-dotnet/Resolution/PackResolver.cs
  • packages/hull-dotnet/Resolution/Pipeline.cs
  • packages/hull-dotnet/Runtime/ReferenceImageRuntime.cs
  • packages/hull-dotnet/Runtime/RuntimeHost.cs
  • packages/hull-dotnet/Shell/HullShell.cs
  • packages/hull-dotnet/Shipyard.Hull.csproj
  • packages/hull-dotnet/tests/RoundTripTests.cs
  • packages/hull-dotnet/tests/Shipyard.Hull.Tests.csproj

Comment thread packages/hull-dotnet/Membrane/Announce.cs Outdated
Comment thread packages/hull-dotnet/Membrane/Invoke.cs
Comment thread packages/hull-dotnet/Resolution/PackResolver.cs
Comment on lines +168 to +193
foreach (var pair in pack.Defaults)
{
var mergeKey = pack.DefaultMergeKeys?.GetValueOrDefault(pair.Key);
if (mergeKey is not null && pair.Value.ValueKind == JsonValueKind.Array)
{
MergeCollection(pair.Key, mergeKey, pair.Value, pack.Name, tierGroup.Key, merged, owners, collectionOwners);
continue;
}

if (scalarContributors.TryGetValue(pair.Key, out var priorPack)
&& !JsonEqual(merged[pair.Key], pair.Value))
{
throw Error(
"same-tier-conflict",
$"packs '{priorPack}' and '{pack.Name}' define default '{pair.Key}' differently at the same specificity tier",
[priorPack, pack.Name], pair.Key);
}

scalarContributors[pair.Key] = pack.Name;
var overrode = owners.TryGetValue(pair.Key, out var previous)
? [.. previous.Overrode, previous.Pack]
: new List<string>();
merged[pair.Key] = pair.Value.Clone();
owners[pair.Key] = (pack.Name, overrode);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

issue (blocking): a key handled as a keyed collection and as a scalar in the same tier silently loses data.

DefaultMergeKeys is per-pack. Two packs at the same specificity tier can disagree about whether one key is a keyed collection.

Take key K and two packs at the same tier:

  • Pack A declares DefaultMergeKeys["K"] and an array value. Line 173 routes to MergeCollection, which writes merged["K"] and owners["K"]. It never writes scalarContributors["K"].
  • Pack B does not declare a merge key for K. Line 177 finds no entry in scalarContributors, so the same-tier conflict check is skipped. Line 190 overwrites the merged array with B's scalar.

The reverse order also loses data. If B runs first, MergeCollection reads merged["K"], sees a non-array at Line 213, starts from an empty entry set, and replaces the scalar with an array.

Neither direction raises same-tier-conflict, so a real catalog conflict resolves by iteration order. Track the key shape per tier and raise same-tier-conflict when the shapes disagree.

Related: Line 171 requires ValueKind == JsonValueKind.Array before using the merge key. If a pack declares a merge key but supplies a non-array value, the code silently falls back to scalar handling. Prefer an explicit error for that malformed input.

🐛 Sketch of a shape guard
             var scalarContributors = new Dictionary<string, string>(StringComparer.Ordinal);
+            var keyShapes = new Dictionary<string, string>(StringComparer.Ordinal);
             foreach (var pack in tierGroup)
             {
                 if (pack.Defaults is null) continue;
                 foreach (var pair in pack.Defaults)
                 {
                     var mergeKey = pack.DefaultMergeKeys?.GetValueOrDefault(pair.Key);
+                    var shape = mergeKey is not null ? "collection" : "scalar";
+                    if (keyShapes.TryGetValue(pair.Key, out var priorShape) && priorShape != shape)
+                    {
+                        throw Error(
+                            "same-tier-conflict",
+                            $"packs disagree on the shape of default '{pair.Key}' at the same specificity tier ('{priorShape}' vs '{shape}')",
+                            [pack.Name], pair.Key);
+                    }
+
+                    keyShapes[pair.Key] = shape;
                     if (mergeKey is not null && pair.Value.ValueKind == JsonValueKind.Array)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/hull-dotnet/Resolution/PackResolver.cs` around lines 168 - 193,
Update the same-tier default resolution around the foreach over pack.Defaults to
track each key’s shape (keyed collection versus scalar) independently of
scalarContributors. Raise same-tier-conflict when another pack uses the opposite
shape, regardless of iteration order, while preserving existing collection
merging and scalar conflict behavior. Also validate that any key declared in
DefaultMergeKeys has an array value; otherwise raise an explicit malformed-input
error instead of falling back to scalar handling.

Comment on lines +251 to +253
private static bool Satisfies(string version, string constraint)
{
if (!TryParseVersion(version, out var actual)) return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

question (non-blocking): an unparseable pack version reports as unsatisfiable, not as malformed.

Line 253 returns false when the actual version does not parse. Line 265 throws version-unsatisfiable when the constraint does not parse. The two malformed inputs therefore behave differently.

TryParseVersion accepts exactly three dot-separated integers. A pack version such as 1.2.0-rc1 fails to parse. CheckVersions then reports that the pack "requires X, but the catalog has X 1.2.0-rc1", which points at the constraint rather than at the unsupported version format. The PR description mentions a pinned preview SDK, so prerelease version strings are plausible in this catalog.

Confirm whether prerelease versions must be supported. If they must not be, raise an explicit parse error instead of returning false.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/hull-dotnet/Resolution/PackResolver.cs` around lines 251 - 253,
Update Satisfies to distinguish an unparseable pack version from a valid version
that fails the constraint: when TryParseVersion cannot parse version, raise the
established explicit version-parse/malformed error instead of returning false.
Preserve the existing false result for valid versions that do not satisfy
constraint, and keep constraint parse failures handled as currently.

Comment on lines +11 to +17
private bool _degraded;
private bool _failNextInvoke;
private readonly List<string> _cancelledJobs = [];

public IReadOnlyList<string> CancelledJobs => _cancelledJobs;
public void SetDegraded(bool degraded) => _degraded = degraded;
public void SetFailNextInvoke(bool fail) => _failNextInvoke = fail;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

issue: ReferenceImageRuntime holds unsynchronized mutable state behind an asynchronous interface.

Cancel calls _cancelledJobs.Add with no synchronization. InProcessTransport.CancelAsync exposes this through an async API, so two callers can reach Add concurrently. Concurrent Add on List<T> can corrupt the internal array or throw. CancelledJobs also returns the live list, so a caller enumerating it during a concurrent Cancel gets an InvalidOperationException.

This type ships in the package rather than in the tests project, and HullShell.CreateReference() wires it up, so the reachable surface is wider than a test double.

🔒️ Proposed fix
-    private readonly List<string> _cancelledJobs = [];
+    private readonly System.Collections.Concurrent.ConcurrentQueue<string> _cancelledJobs = new();

-    public IReadOnlyList<string> CancelledJobs => _cancelledJobs;
+    public IReadOnlyList<string> CancelledJobs => _cancelledJobs.ToArray();
-    public void Cancel(CancelRequest request) => _cancelledJobs.Add(request.JobId);
+    public void Cancel(CancelRequest request) => _cancelledJobs.Enqueue(request.JobId);

The change touches concurrency, which the review policy treats as a control-point area. Route this to an independent reviewer rather than resolving it by self-assertion.

As per path instructions, "Flag CP-touching changes (financial/audit/security/compliance/concurrency) as needing an independent reviewer, not self-assertion (A2)."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/hull-dotnet/Runtime/ReferenceImageRuntime.cs` around lines 11 - 17,
Synchronize all mutable state in ReferenceImageRuntime, especially
_cancelledJobs updates performed by Cancel and reads exposed through
CancelledJobs, so concurrent InProcessTransport.CancelAsync calls and
enumeration are safe. Return a snapshot or otherwise prevent callers from
observing the live list, while preserving the existing API behavior. Because
this is a concurrency control-point change, flag it for independent review
rather than self-assertion.

Source: Path instructions

Comment on lines +20 to +32
public Task<RuntimeManifest> AnnounceAsync(CancellationToken cancellationToken = default) =>
Task.FromResult(host.Announce());
public Task<NegotiateOffer> NegotiateOfferAsync(CancellationToken cancellationToken = default) =>
Task.FromResult(host.NegotiateOffer());
public Task<HealthProbe> HealthAsync(string kind, CancellationToken cancellationToken = default) =>
Task.FromResult(host.Health(kind));
public Task<CapabilityResult> InvokeAsync(InvokeRequest request, CancellationToken cancellationToken = default) =>
Task.FromResult(host.Invoke(request));
public Task CancelAsync(CancelRequest request, CancellationToken cancellationToken = default)
{
host.Cancel(request);
return Task.CompletedTask;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

issue (non-blocking): InProcessTransport does not fully honor the IRuntimeTransport async contract, in two ways.

First, a host exception escapes synchronously. If host.Invoke(request) throws, the exception leaves InvokeAsync before a Task is returned. LocalSubprocessTransport instead returns a faulted task for its failures. A caller that separates task creation from await observes different behavior from the two implementations of the same interface.

Second, cancellationToken is accepted and discarded in all five methods. An already-cancelled token still runs the full operation.

🐛 Proposed fix
 public Task<CapabilityResult> InvokeAsync(InvokeRequest request, CancellationToken cancellationToken = default)
-    Task.FromResult(host.Invoke(request));
+{
+    if (cancellationToken.IsCancellationRequested)
+    {
+        return Task.FromCanceled<CapabilityResult>(cancellationToken);
+    }
+
+    try
+    {
+        return Task.FromResult(host.Invoke(request));
+    }
+    catch (Exception ex)
+    {
+        return Task.FromException<CapabilityResult>(ex);
+    }
+}

Apply the same shape to AnnounceAsync, NegotiateOfferAsync, HealthAsync, and CancelAsync. A small private helper that wraps a Func<T> will keep this from becoming five near-identical blocks.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/hull-dotnet/Runtime/RuntimeHost.cs` around lines 20 - 32, Update
RuntimeHost’s five async methods—AnnounceAsync, NegotiateOfferAsync,
HealthAsync, InvokeAsync, and CancelAsync—to honor cancellationToken before
invoking the host and to return faulted tasks when host operations throw instead
of throwing synchronously. Add and reuse a private generic helper for
synchronous Func<T> operations, with equivalent handling for the void Cancel
operation.

Comment on lines +15 to +28
private readonly Dictionary<string, RuntimeConnection> _connections = new(StringComparer.Ordinal);

public CompositionManifest Composition => options.Composition;
public IReadOnlyList<RuntimeConnection> Runtimes => _connections.Values.ToArray();

public async Task<RuntimeConnection> ConnectAsync(
IRuntimeTransport transport,
CancellationToken cancellationToken = default)
{
var connection = await RuntimeConnectionLifecycle.ConnectAsync(
transport, options.NegotiationProfile, cancellationToken).ConfigureAwait(false);
_connections[connection.RuntimeId] = connection;
return connection;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

issue: _connections is mutated without synchronization.

ConnectAsync is a public async method that writes to a plain Dictionary at Line 26. Runtimes, ProbeAsync, Resolve, and InvokeAsync read the same instance. If a caller connects two runtimes concurrently, or invokes while a connect completes, the concurrent write plus read can throw InvalidOperationException or leave the dictionary in a corrupt state.

HullShell is the shell entry point, so callers will hold one instance and use it from several request paths. Use ConcurrentDictionary<string, RuntimeConnection>, or document that the type is not thread-safe.

🔒️ Proposed fix
-    private readonly Dictionary<string, RuntimeConnection> _connections = new(StringComparer.Ordinal);
+    private readonly ConcurrentDictionary<string, RuntimeConnection> _connections = new(StringComparer.Ordinal);

Add the import:

+using System.Collections.Concurrent;
 using Shipyard.Hull.Contracts;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private readonly Dictionary<string, RuntimeConnection> _connections = new(StringComparer.Ordinal);
public CompositionManifest Composition => options.Composition;
public IReadOnlyList<RuntimeConnection> Runtimes => _connections.Values.ToArray();
public async Task<RuntimeConnection> ConnectAsync(
IRuntimeTransport transport,
CancellationToken cancellationToken = default)
{
var connection = await RuntimeConnectionLifecycle.ConnectAsync(
transport, options.NegotiationProfile, cancellationToken).ConfigureAwait(false);
_connections[connection.RuntimeId] = connection;
return connection;
}
using System.Collections.Concurrent;
using Shipyard.Hull.Contracts;
private readonly ConcurrentDictionary<string, RuntimeConnection> _connections = new(StringComparer.Ordinal);
public CompositionManifest Composition => options.Composition;
public IReadOnlyList<RuntimeConnection> Runtimes => _connections.Values.ToArray();
public async Task<RuntimeConnection> ConnectAsync(
IRuntimeTransport transport,
CancellationToken cancellationToken = default)
{
var connection = await RuntimeConnectionLifecycle.ConnectAsync(
transport, options.NegotiationProfile, cancellationToken).ConfigureAwait(false);
_connections[connection.RuntimeId] = connection;
return connection;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/hull-dotnet/Shell/HullShell.cs` around lines 15 - 28, Replace the
plain _connections Dictionary in HullShell with a thread-safe
ConcurrentDictionary, adding the required namespace import, while preserving the
existing ordinal key comparison and public Runtimes behavior. Ensure
ConnectAsync continues to publish connections through the concurrent collection
so concurrent ConnectAsync, reads, ProbeAsync, Resolve, and InvokeAsync calls
are safe.

Comment on lines +76 to +98
var resolution = Resolve(request.CapabilityId);
if (resolution.ChosenProviderId is null)
{
return new CapabilityResult(
$"job:unresolved:{request.CorrelationId}",
"failed", 0, Array.Empty<Artifact>(), new Usage("call", 0, null, resolution.Tier),
new CapabilityError("membrane", false, $"membrane.resolution_{resolution.ResolutionState.Replace('-', '_')}", resolution.Reason, null));
}

var connection = _connections.Values.FirstOrDefault(item =>
item.Registry.ByCapability.ContainsKey(request.CapabilityId)
&& item.Negotiation.AcceptedCapabilities.Contains(request.CapabilityId, StringComparer.Ordinal));
if (connection is null)
{
return new CapabilityResult(
$"job:no-runtime:{request.CorrelationId}",
"failed", 0, Array.Empty<Artifact>(), new Usage("call", 0, null, resolution.Tier),
new CapabilityError("membrane", false, "membrane.no_connected_runtime", $"no connected runtime hosts '{request.CapabilityId}'", null));
}

var effectiveContext = context ?? options.InvokeContext;
return await RuntimeConnectionLifecycle.InvokeAsync(
connection, request, effectiveContext, cancellationToken).ConfigureAwait(false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check whether InvokeRequest or the transport carries a provider id, and how TS binds the resolved provider.
set -euo pipefail

fd -t f -e cs . packages/hull-dotnet --exec rg -n -C3 'record InvokeRequest|ChosenProviderId|ProviderCandidate' {}

fd -t d 'resolution' | while IFS= read -r dir; do
  echo "=== $dir ==="
  rg -n -C3 'chosenProviderId|providerId' "$dir"
done

Repository: Harborline-Software/shipyard

Length of output: 4360


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Repository files matching relevant names:"
fd -t f 'HullShell|InvokeRequest|RuntimeConnectionLifecycle|ResolutionResult|ProviderCandidate|CapabilityRequest|InvokeRequest' . packages/hull-dotnet packages packages/hull-dotnet --max-depth 4 | sed -n '1,160p'

echo
echo "HullShell.cs outline:"
ast-grep outline packages/hull-dotnet/Shell/HullShell.cs --view compact 2>/dev/null || true

echo
echo "HullShell.cs relevant section:"
sed -n '1,140p' packages/hull-dotnet/Shell/HullShell.cs | cat -n

echo
echo "Resolution pipeline candidates:"
sed -n '180,225p' packages/hull-dotnet/Shell/ResolutionPipeline.cs | cat -n

echo
echo "Search for Resolve results and InvokeRequest usage:"
rg -n -C3 'Resolve\(|ChosenProviderId|InvokeAsync\(|InvokeRequest\(|ProviderId|providerId|ProviderId' packages/hull-dotnet packages -g '*.cs' | sed -n '1,260p'

Repository: Harborline-Software/shipyard

Length of output: 6620


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Find ResolutionPipeline.cs:"
fd -t f 'ResolutionPipeline\.cs|RuntimeConnectionLifecycle\.cs|InvokeRequest\.cs|ProviderCandidate\.cs|Resolve.*\.cs' . -d 6 | sed -n '1,120p'

echo
echo "List packages top-level directory names:"
find packages -maxdepth 2 -type d | sort | sed -n '1,160p'

echo
echo "Search provider/chosen provider identifiers:"
rg -n -C3 'record ProviderCandidate|record ResolutionResult|ChosenProviderId|ProviderId|Choose\(|InvokeRequest\(' . --glob '*.cs' | sed -n '1,260p'

Repository: Harborline-Software/shipyard

Length of output: 36127


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Locate Resolve provider selection implementation references:"
rg -n -C4 'DefaultConfigurationResolver|Choose\(.*IReadOnlyList<ProviderCandidate>|ProviderLicense|license|Hardware|HardwareResolver|StaticHardwareResolver' packages/hull-dotnet/Resolution packages/hull-dotnet/Shell packages/hull-dotnet/Contracts packages/hull-dotnet/tests --glob '*.cs' | sed -n '1,260p'

echo
echo "Inspect ProviderCandidate wire mapping:"
sed -n '60,105p' packages/hull-dotnet/Resolution/Pipeline.cs | cat -n
rg -n -C4 'class ProviderCandidate|sealed record ProviderCandidate|ProviderCandidateWire|ProviderCandidateJson' packages/hull-dotnet/Contracts packages/hull-dotnet/Resolution --glob '*.cs' | sed -n '1,220p'

echo
echo "Inspect Resolve result consumers/usages in hull-dotnet:"
rg -n -C3 'Resolve\(|ChosenProviderId|InvokeAsync\(|RuntimeConnectionLifecycle\.InvokeAsync|InvokeRequest\(' packages/hull-dotnet --glob '*.cs' | sed -n '1,260p'

Repository: Harborline-Software/shipyard

Length of output: 23212


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Search for BankImport Core model / provider ID semantics:"
rg -n -C4 'BankImportCore|BankImportSource|ProviderId|providerId|FeedSource|FileSource|Invoked' packages -g '*.cs' | sed -n '1,260p'

echo
echo "Locate RuntimeConnectionLifecycle and RuntimeHost implementation:"
fd -t f 'RuntimeConnectionLifecycle\.cs|RuntimeHost\.cs|Invoke\.cs' packages/hull-dotnet | sort

echo
echo "RuntimeConnectionLifecycle.cs:"
cat -n packages/hull-dotnet/Membrane/RuntimeConnectionLifecycle.cs

echo
echo "RuntimeHost.cs:"
cat -n packages/hull-dotnet/Runtime/RuntimeHost.cs

echo
echo "Runtime lifecycle protocol usage around Invoke:"
cat -n packages/contracts/Generated/CarrierProtocol.g.cs | sed -n '1840,1900p'
rg -n -C4 'HullHostInvokeRequest|HullInvokeRequest|Choose|ProviderId|capabilityId' packages/contracts packages/hull-dotnet --glob '*.cs' | sed -n '1,260p'

Repository: Harborline-Software/shipyard

Length of output: 26230


🌐 Web query:

Shipyard Hull dotnet ProviderCandidate Wire mapping ProviderCandidate Wire

💡 Result:

The term "ProviderCandidate" appears in two distinct contexts related to.NET and unrelated software architectures, while "Shipyard" and "Wire mapping" do not share a single common technical implementation in this context. 1..NET Response Compression: In the ASP.NET Core ResponseCompression middleware, ProviderCandidate is an internal class (defined in ResponseCompressionProvider.cs) used to represent a compression provider that is a candidate for handling a request based on the client's Accept-Encoding header [1][2]. It encapsulates the provider, its encoding name (e.g., "gzip", "br"), quality score, and priority [1][3]. It is not related to a "Shipyard" product or "wire mapping" in this context. 2. Unrelated Software Architectures: - There are various projects named "Shipyard" in the.NET ecosystem (e.g., a tool for packaging.NET apps on Linux [4], an IPFS client library [5], and a framework-agnostic UI tool [6]), but none of these standard repositories feature a "ProviderCandidate" class or "wire mapping" functionality. - The class ProviderCandidate exists in unrelated projects, such as the Rust sourceright crate (where it handles academic provider metadata) [7][8] and the omegon routing logic (where it handles model routing for LLM providers) [9][10]. These are also unrelated to.NET "wire mapping." If you are encountering a "wire mapping" error or configuration involving a "Shipyard" component, it is likely a highly specific or proprietary implementation, or a conflation of these separate concepts. There is no standard.NET library or "Shipyard" component that performs "wire mapping" using a "ProviderCandidate."

Citations:


issue [blocking]: use resolution.ChosenProviderId at invoke time.

Resolve() selects a provider after entitlement, hardware, licensing, and configuration checks and stores the result in resolution.ChosenProviderId, while InvokeAsync() only checks that the result is not null. The method then selects the first compatible connection and forwards request unchanged, so requests can run on a different provider than the one selected by the resolution pipeline.

Forward the chosen provider id through the invoke path, or select the specific runtime whose announced providers include resolution.ChosenProviderId.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/hull-dotnet/Shell/HullShell.cs` around lines 76 - 98, Update the
invocation flow in HullShell’s resolution handling to honor
resolution.ChosenProviderId after Resolve(request.CapabilityId) succeeds. Select
the connection whose announced providers include that exact chosen provider, or
forward the chosen provider through RuntimeConnectionLifecycle.InvokeAsync, and
do not fall back to an arbitrary compatible capability connection.

Comment on lines +87 to +103
var result = await shell.InvokeAsync(new InvokeRequest(
CapabilityIds.BankImport,
new BankImportCore(
new BankImportFileSource("a", "s.csv"),
"acct",
null,
1000),
new Dictionary<string, System.Text.Json.JsonElement>(),
[new RequestAttachment("a", "text/csv", "s.csv", null, null)],
"idem-bank",
"corr-bank-na",
"sync"));

Assert.Equal("failed", result.Status);
Assert.Equal("membrane", result.Error?.FaultDomain);
Assert.Equal("membrane.resolution_not_in_edition", result.Error?.Code);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

issue: the SEC-2 idempotency guard has no test that exercises the throwing branch.

This test passes "idem-bank" as the idempotency key, so Invoke.AssertIdempotencyKey never throws. IdempotencyKeyRequiredException is the fail-closed control for a financial capability, and no test in the suite reaches it. Two other control paths are also untested: Negotiate.Reconcile returning Compatible == false, and redaction through RedactingLogSink.

The idempotency guard and the redaction control are both control points. Please route them to an independent reviewer rather than asserting the behavior in the PR description.

💚 Proposed additional test
+    [Fact]
+    public void Blank_idempotency_key_fails_closed_for_financial_capability()
+    {
+        var request = new InvokeRequest(
+            CapabilityIds.BankImport,
+            new BankImportCore(new BankImportFileSource("a", "s.csv"), "acct", null, 1000),
+            new Dictionary<string, System.Text.Json.JsonElement>(),
+            [new RequestAttachment("a", "text/csv", "s.csv", null, null)],
+            "   ",
+            "corr-bank-blank",
+            "sync");
+
+        Assert.Throws<IdempotencyKeyRequiredException>(() => Invoke.AssertIdempotencyKey(request));
+    }

As per path instructions, "the change's CI suite must actually exercise it (A1)" and "Flag CP-touching changes (financial/audit/security/compliance/concurrency) as needing an independent reviewer, not self-assertion (A2)".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
var result = await shell.InvokeAsync(new InvokeRequest(
CapabilityIds.BankImport,
new BankImportCore(
new BankImportFileSource("a", "s.csv"),
"acct",
null,
1000),
new Dictionary<string, System.Text.Json.JsonElement>(),
[new RequestAttachment("a", "text/csv", "s.csv", null, null)],
"idem-bank",
"corr-bank-na",
"sync"));
Assert.Equal("failed", result.Status);
Assert.Equal("membrane", result.Error?.FaultDomain);
Assert.Equal("membrane.resolution_not_in_edition", result.Error?.Code);
}
var result = await shell.InvokeAsync(new InvokeRequest(
CapabilityIds.BankImport,
new BankImportCore(
new BankImportFileSource("a", "s.csv"),
"acct",
null,
1000),
new Dictionary<string, System.Text.Json.JsonElement>(),
[new RequestAttachment("a", "text/csv", "s.csv", null, null)],
"idem-bank",
"corr-bank-na",
"sync"));
Assert.Equal("failed", result.Status);
Assert.Equal("membrane", result.Error?.FaultDomain);
Assert.Equal("membrane.resolution_not_in_edition", result.Error?.Code);
}
[Fact]
public void Blank_idempotency_key_fails_closed_for_financial_capability()
{
var request = new InvokeRequest(
CapabilityIds.BankImport,
new BankImportCore(new BankImportFileSource("a", "s.csv"), "acct", null, 1000),
new Dictionary<string, System.Text.Json.JsonElement>(),
[new RequestAttachment("a", "text/csv", "s.csv", null, null)],
" ",
"corr-bank-blank",
"sync");
Assert.Throws<IdempotencyKeyRequiredException>(() => Invoke.AssertIdempotencyKey(request));
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/hull-dotnet/tests/RoundTripTests.cs` around lines 87 - 103, Extend
the round-trip test coverage around the existing bank-import test to exercise
the SEC-2 throwing path by omitting or invalidating the idempotency key and
asserting the fail-closed IdempotencyKeyRequiredException behavior. Add focused
tests for Negotiate.Reconcile returning Compatible == false and RedactingLogSink
redacting sensitive values, using the existing test infrastructure. Mark these
control-point changes for independent review rather than relying on
self-assertion.

Source: Path instructions

Align the .NET membrane with the TypeScript reference and add load-bearing tests.
Apply the security review selectors to the .NET Hull parity surface.
@github-actions github-actions Bot added the needs-deep-review Touches a change_type selector; deep review required before merge label Aug 5, 2026
ctwoodwa and others added 2 commits August 5, 2026 02:28
…or taxonomy closed

Merged collection defaults now emit in insertion order matching the TS Map
semantics; the duplicate-announcement refusal becomes a membrane-local typed
exception so CompositionError keeps exactly the five compose-time reasons;
the range-grammar comment states the narrowing honestly; the secret log
assertion binds the substring predicate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ctwoodwa ctwoodwa added the deep-review-passed code-reviewer deep pipeline ran and its verdict was accepted label Aug 5, 2026
@ctwoodwa
ctwoodwa enabled auto-merge (squash) August 5, 2026 06:33
@github-actions github-actions Bot removed the needs-deep-review Touches a change_type selector; deep review required before merge label Aug 5, 2026
…er pin

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ctwoodwa
ctwoodwa merged commit 8ad8c7e into main Aug 5, 2026
50 of 51 checks passed
@ctwoodwa
ctwoodwa deleted the feat/3652-hull-dotnet branch August 5, 2026 06:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

deep-review-passed code-reviewer deep pipeline ran and its verdict was accepted

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant