Skip to content

Design and persist a canonical routing/reachability data model on user-computer-profile (local inbound registry + reverse-hub + direct HTTP routes) #1593

Closed

Description

@JoshuaRowePhantom

Summary

A named remote component in a split Copilot manifest binds to a remote user-computer-profile
({"type":"user-computer-profile","entity-id":"<profile-c-id>"}) and reaches
UserComputerProfileTransportFactory. Routing fails for whole classes of reachable targets because
the router has no durable, credential-free model of how a profile can be reached. Today it can
only synthesize a route from the initiating process's outbound-configured hub URLs, so it cannot
route to:

  • a target already registered inbound in this very process's live hub registry (hub A invoking a
    registered client C);
  • a target that publishes a direct receive-only HTTP listener (no outbound reverse registration
    at all);
  • a target that registered with a different hub than the caller's configured one (client B
    invoking client C through hub A);
  • a target reachable by more than one route (multiple hubs, or a hub route plus a direct route)
    with priority/fallback.

This issue supersedes the prior "connection descriptors must never be persisted" position that an
earlier revision of this issue adopted. The design goal is: define and persist a canonical
routing/reachability data model on the target user-computer-profile
, keyed by stable route id,
carrying only credential-free routing metadata, and define the exact resolution precedence that
consults (in order) the local profile, the live inbound registry, the persisted routes, and finally
a trusted transient override — never silently falling back to local. The one lesson retained from
#1590 is unchanged: every persisted entity in tests and production must go through the composed,
schema-validated persistence path (the ValidatingEntitySeedFixture pattern), so the new
reachability model must be a real, schema-declared property — not a hand-seeded fake.

Strictness policy (this revision). A prior draft of this issue's schema sketch left the transport
descriptor open: any type plus arbitrary extra fields validated, and a not-based denylist of
secret-looking keys stood in for real validation. That is replaced here. Every schema layer below
— reachability, the routes map, each route entry, and the descriptor itself — is a closed
allowlist
: additionalProperties: false / unevaluatedProperties: false (as the composition shape
requires) at every object layer, and the descriptor is a closed oneOf discriminated union of
exactly the transport kinds this codebase actually persists. Unknown descriptor types are always
rejected. Adding a transport kind is an explicit schema-and-code change, never a runtime plugin
extension point. The routing/lifecycle design, the resolution precedence, and the A/B/C/D end-to-end
plan are unchanged from the prior revision; only the schema's openness is tightened.

Current Failure

Runtime symptom

Phantom.Workspaces.Transport/UserComputerProfileTransportFactory.cs — ConnectToAsync, for a
non-local target entity-id:

  1. if the request descriptor carries a transient connection-descriptor, route through it;
  2. else if reverseHttpHubUrls.Count > 0, synthesize
    {"type":"reverse-http","hub-urls":[<configured hubs>],"entity-id":"<profile-c-id>"};
  3. else throw:
Remote user computer profile descriptor '<profile-c-id>' has no transient route and no
configured reverse HTTP hub.

reverseHttpHubUrls is the only persistent source of routes, and it describes the caller's
outbound configuration, not the target's reachability.

The stale message User computer profile entity '<id>' does not contain a connection-descriptor object. from before commit 508cbc04 can no longer be produced by current code; a binary still
emitting it predates this analysis and must be rebuilt. This issue is written against current code.

Root cause — two coupled gaps

Gap 1 — no durable reachability model. After 508cbc04 (Fix #1590) the profile schema
deliberately carries no routing metadata and the router reads none. There is no persisted,
credential-free description of how to reach a given profile that an arbitrary caller (a peer, a
non-Web daemon, a fresh process) can discover from the target entity. Every non-trivial topology
above requires the caller to already hold outbound hub configuration for the target, which is not
generally true.

Gap 2 — the host's own live inbound registry is invisible to the router.
Phantom.Workspaces/Services/WorkspacesWebHost.cs (StartAsync, when RemoteHosting.Enabled)
creates its own ReverseHttpServerTransportFactory(this.statusRegistry) and registers it in a
private TransportRegistry exposed over HTTP. That registry's authenticated live registrations
(what the status pane shows as "registered inbound") is a disjoint object graph from the
composition's UserComputerProfileTransportFactory. Nothing hands the live registrations to the
router, so hub A cannot relay to a client it is actively hosting.

reverseHttpHubUrls is itself populated (WorkspacesTransportComposition) only from outbound
hubFactories.HubUrl, and MainWindowViewModel.BuildReverseHttpHubFactories returns outbound
factories only for a WebRepositorySource. A daemon that hosts the hub but reads from a
local-git / MongoDb DAL therefore has empty reverseHttpHubUrls and no visible inbound registry —
the canonical failing host.

Both gaps must close together: the live registry gives immediate reachability while connected; the
persisted reachability model gives durable, discoverable reachability for peers and across
processes.

Canonical Data Model

Chosen shape: a reachability.routes map keyed by stable route id, on user-computer-profile

The target profile entity gains one top-level reachability object containing only a map of
route slots (routes) keyed by a stable, deterministic route id:

user-computer-profile
└── reachability          (object; only property is "routes"; required when present)
    └── routes: { <route-id>: <reachability-route> , ... }   (0..16 entries; unknown-shaped keys rejected)

Each reachability-route separates stable route identity (the map key) from the current
endpoint
(the descriptor payload) and adds the minimum lifecycle/ownership fields. The route entry
schema is closed: exactly these fields, nothing else.

Field Type Why it is required (minimum-necessary justification)
descriptor reachable-transport-descriptor.json (closed oneOf, shared $ref) The credential-free, closed, type-discriminated transport payload (http, reverse-http only — see Persisted vs. Executor Descriptor Split). Reuses the shared schema; not duplicated.
owner-profile-entity-id uuid (core.json#/$defs/entity-id) Publisher/owner identity. A publisher (direct listener or a per-hub reverse client) upserts/removes only slots it owns; readers reject slots whose owner does not match the expected publisher. Prevents one factory clobbering an unrelated route.
last-confirmed date-time Freshness. Set on each successful bind/registration confirmation; lets readers and operators reason about staleness independently of hard expiry.
expires-at date-time Lease/crash protection. Readers ignore expired routes, so a crashed publisher's slot self-heals without a graceful removal. expires-at must be strictly after last-confirmed. JSON Schema (2020-12) has no cross-property date-time comparison keyword, so this ordering is enforced by the route store at write time (IReachabilityRouteStore), not by the schema; a write that violates it is rejected before the patch is applied.
priority integer, 0-1000 (optional, default 100, lower is preferred) Deterministic, bounded ordering when multiple routes exist (e.g. prefer a cheap direct route over a relayed hub route). Optional so single-route profiles stay minimal; bounded so a route cannot be given a value that overflows/ambiguously sorts against a resolver's numeric assumptions.

No other properties are permitted on a route entry — no free-form arrays, no per-route auth
material, no history.

Route id (map key) is an opaque, non-empty string, but the schema constrains it to exactly the
two shapes publishers actually derive, via propertyNames (equivalent in effect to
patternProperties keyed on the same pattern, but avoids duplicating the pattern between the key
matcher and the value schema — the value schema is supplied once via additionalProperties):

  • Direct receive-only HTTP listener → the literal direct-http.
  • Reverse-HTTP client, one slot per hub → reverse-http:<hub-profile-entity-id>, where the suffix
    must be a syntactically valid UUID.

A route id that matches neither shape is rejected at the schema level, not merely ignored.

Why a map keyed by stable route id (and not the alternatives)

  • Single connection-descriptor object (the old proposal): rejected. Cannot express multiple
    hubs/routes; every publisher is a last-writer-wins clobber of a shared field; no per-route
    ownership or lifecycle.
  • connection-descriptors array: rejected. No stable identity → idempotent upsert/removal
    requires scanning and matching; concurrent independent publishers race on array position and can
    drop each other's entries under optimistic-concurrency retries.
  • Map keyed by stable route id: chosen. Each independent publisher owns exactly one key and
    mutates only that key with a single-slot JSON-patch, so concurrent hub/direct publishers do not
    overwrite one another; upsert/remove are naturally idempotent; ownership and lease live per slot.
  • Separate reachability/route entities + relationships: considered, deferred (see
    Considered / Background). Better history/concurrency isolation but heavier; not required for the
    bounded, publisher-owned slot set here.

Persistence location

reachability is a top-level property of user-computer-profile (not a separate entity in the
canonical option). Because the DAL composes entity.json + type schemas and enforces
unevaluatedProperties: false on the final composed schema (never on each individual per-type
schema file, which would conflict with sibling entity-type schemas contributing their own
properties — see the Schema Composition Conventions note below), the property must be schema-declared
to persist at all — which is exactly what makes it validate under the #1590 pattern. Concurrent
writers are handled by per-slot patches under optimistic concurrency (see Route Publication
Lifecycle).

JSON examples

All values are placeholders. Machines are A (hub/daemon), B/C (reverse clients), D
(direct HTTP listener). Every example below validates under the strict schemas in the next section;
none contain a local descriptor, an unknown type, or an extra property at any layer.

C, registered inbound with hub A (one reverse-http slot):

{
  "entity-types": ["user-computer-profile", "entity"],
  "entity-id": "cccccccc-cccc-cccc-cccc-cccccccccccc",
  "computer-reference": ["computers", "org", "machine-c"],
  "user-reference": ["users", "org", "worker"],
  "reachability": {
    "routes": {
      "reverse-http:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa": {
        "descriptor": {
          "type": "reverse-http",
          "hub-urls": ["https://hub-a.example/reverse"],
          "entity-id": "cccccccc-cccc-cccc-cccc-cccccccccccc"
        },
        "owner-profile-entity-id": "cccccccc-cccc-cccc-cccc-cccccccccccc",
        "priority": 100,
        "last-confirmed": "2026-09-18T18:00:00Z",
        "expires-at": "2026-09-18T18:02:00Z"
      }
    }
  }
}

D, a direct receive-only HTTP listener (no reverse registration):

{
  "reachability": {
    "routes": {
      "direct-http": {
        "descriptor": { "type": "http", "url": "https://machine-d.example:5001/" },
        "owner-profile-entity-id": "dddddddd-dddd-dddd-dddd-dddddddddddd",
        "priority": 50,
        "last-confirmed": "2026-09-18T18:00:00Z",
        "expires-at": "2026-09-18T18:02:00Z"
      }
    }
  }
}

Heterogeneous target with priority/fallback (a direct route plus two hub routes):

{
  "reachability": {
    "routes": {
      "direct-http": {
        "descriptor": { "type": "http", "url": "https://machine-e.example:5001/" },
        "owner-profile-entity-id": "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee",
        "priority": 50,
        "last-confirmed": "2026-09-18T18:00:00Z",
        "expires-at": "2026-09-18T18:02:00Z"
      },
      "reverse-http:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa": {
        "descriptor": { "type": "reverse-http", "hub-urls": ["https://hub-a.example/reverse"], "entity-id": "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee" },
        "owner-profile-entity-id": "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee",
        "priority": 100,
        "last-confirmed": "2026-09-18T18:00:00Z",
        "expires-at": "2026-09-18T18:02:00Z"
      },
      "reverse-http:22222222-2222-2222-2222-222222222222": {
        "descriptor": { "type": "reverse-http", "hub-urls": ["https://hub-a2.example/reverse"], "entity-id": "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee" },
        "owner-profile-entity-id": "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee",
        "priority": 100,
        "last-confirmed": "2026-09-18T18:00:00Z",
        "expires-at": "2026-09-18T18:02:00Z"
      }
    }
  }
}

JSON Schema (strict)

Three shared schema files are added. Both are closed at every object layer; there is no open
{ "type": ... } plug-in surface anywhere in this design.

Schema composition conventions used below

Per the repository's Draft 2020-12 composer (docs/design/architecture.md §8–9): the final
composed
entity schema enforces unevaluatedProperties: false; individual per-entity-type schema
files must not redeclare it at their own top level (that would fight sibling entity-type schemas
contributing properties to the same composed document — this is why user-computer-profile.json
itself has no top-level unevaluatedProperties, and this rewrite does not add one). Independent
nested $defs/$ref scopes — a nested object property, or a oneOf branch — are not re-composed
by the DAL and so close themselves with unevaluatedProperties: false (the dominant house style,
matching llm-trust-profile.json, mime-attachment.json, workspace.json, and the
workspace-entities-data-access-layer.json query-clause discriminated union). A discriminated
union is written as type: object + required: [<discriminator>] + oneOf of fully self-contained
branches, each with its own properties/required/unevaluatedProperties: false — never as
allOf of if/then fragments, which cannot each independently close the object without the outer
wrapper needing (and fighting over) unevaluatedProperties itself. This matches
workspace-entities-data-access-layer.json's query-clause exactly and is the pattern used
throughout below.

Phantom.Workspaces.Data.Core/JsonSchemas/reachable-transport-descriptor.json (new, shared)

The only descriptor shape permitted at reachability.routes[*].descriptor. Persisted-only;
excludes local (a local route is derived from the current process's own session identity, never a
persisted fact about a remote profile).

{
  "$id": "https://schemas.workspaces.phantom.to/workspaces/data/core/reachable-transport-descriptor.json",
  "description": "Closed, credential-free, type-discriminated descriptor for a durable, PERSISTED transport route. This is the only descriptor shape permitted at reachability.routes[*].descriptor. Only transport kinds safe to persist and re-dial from cold storage are represented. Unknown 'type' values are always rejected: there is no open/plugin extension point. Adding a transport kind is an explicit schema-and-code change (see #1593 'Versioning / Compatibility'), never a data-only extension.",
  "type": "object",
  "required": ["type"],
  "oneOf": [
    { "$ref": "#/$defs/http-descriptor" },
    { "$ref": "#/$defs/reverse-http-descriptor" }
  ],
  "$defs": {
    "http-descriptor": {
      "description": "Direct receive-only HTTP(S) listener. Exactly 'type' and 'url'.",
      "type": "object",
      "properties": {
        "type": { "const": "http" },
        "url": {
          "type": "string",
          "format": "uri",
          "pattern": "^https?://(?!.*@)\\S+$",
          "description": "Absolute http(s) URL. The pattern rejects an embedded userinfo ('user:pass@host') as a defense-in-depth schema-level guard. HTTPS-only policy, and rejection of credentials smuggled in the query string or fragment, are enforced by the application trust/normalization layer (schema regex over query/fragment content is not a reliable security boundary)."
        }
      },
      "required": ["type", "url"],
      "unevaluatedProperties": false
    },
    "reverse-http-descriptor": {
      "description": "Reverse-HTTP client registered with one or more hubs. Exactly 'type', 'hub-urls', 'entity-id'.",
      "type": "object",
      "properties": {
        "type": { "const": "reverse-http" },
        "hub-urls": {
          "type": "array",
          "items": {
            "type": "string",
            "format": "uri",
            "pattern": "^https?://(?!.*@)\\S+$"
          },
          "minItems": 1,
          "maxItems": 8,
          "uniqueItems": true
        },
        "entity-id": { "$ref": "core.json#/$defs/entity-id" }
      },
      "required": ["type", "hub-urls", "entity-id"],
      "unevaluatedProperties": false
    }
  }
}

Phantom.Workspaces.Data.Core/JsonSchemas/reachability-route.json (new, shared)

{
  "$id": "https://schemas.workspaces.phantom.to/workspaces/data/core/reachability-route.json",
  "description": "A single durable, credential-free reachability route slot for a user-computer-profile. The map key (route id) is the stable route identity; the descriptor is the current endpoint. Closed: exactly these five fields.",
  "type": "object",
  "properties": {
    "descriptor": { "$ref": "reachable-transport-descriptor.json" },
    "owner-profile-entity-id": { "$ref": "core.json#/$defs/entity-id" },
    "priority": {
      "type": "integer",
      "minimum": 0,
      "maximum": 1000,
      "default": 100,
      "description": "Lower is preferred."
    },
    "last-confirmed": { "type": "string", "format": "date-time" },
    "expires-at": {
      "type": "string",
      "format": "date-time",
      "description": "Must be strictly after 'last-confirmed'; enforced by IReachabilityRouteStore at write time, not by JSON Schema."
    }
  },
  "required": ["descriptor", "owner-profile-entity-id", "last-confirmed", "expires-at"],
  "unevaluatedProperties": false
}

reachability block, added to user-computer-profile.json properties

"reachability": {
  "description": "Durable, credential-free reachability model. The ONLY permitted property is 'routes'.",
  "type": "object",
  "properties": {
    "routes": {
      "type": "object",
      "description": "Map of route-id -> reachability-route. 0..16 entries. propertyNames constrains the key shape (equivalent to patternProperties keyed on the same pattern, without duplicating it against the value schema); additionalProperties supplies the single value schema for every accepted key.",
      "propertyNames": {
        "pattern": "^(direct-http|reverse-http:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$"
      },
      "minProperties": 0,
      "maxProperties": 16,
      "additionalProperties": { "$ref": "reachability-route.json" }
    }
  },
  "required": ["routes"],
  "unevaluatedProperties": false
}

routes: {} (zero entries) is valid and expected during self-heal (a crashed publisher's slot has
expired and not yet been re-published); what is rejected is reachability present without a
routes key at all (the object's only allowed content), any route id that does not match the
pattern above, and more than 16 route entries.

Persisted vs. Executor Descriptor Split

agent-session.json's executor-bindings (session and components.*) currently accept any
object with additionalProperties: true, because the executor selection is broader than a persisted
reachability route: it must express local (this process) and user-computer-profile (a targeted
selection, optionally forcing a specific transient route for a trusted caller), in addition to the
same http/reverse-http transports reachability uses. This broader shape must not be reused for
reachability.routes[*].descriptor
— doing so would let a persisted route declare type: "local",
which is meaningless (whose "local" would it be?) and exactly the kind of ambiguity strictness is
meant to remove. Conversely, reusing the narrow persisted union for executor bindings would make
local/targeted user-computer-profile selections impossible to express there. The two are
therefore separate, both-closed schemas:

Phantom.Workspaces.Data.Core/JsonSchemas/executor-connection-descriptor.json (new, shared)

{
  "$id": "https://schemas.workspaces.phantom.to/workspaces/data/core/executor-connection-descriptor.json",
  "description": "Closed, type-discriminated TRANSIENT executor/connection descriptor consumed by ITransportFactory.ConnectToAsync at request time and stored in agent-session.json 'executor-bindings' (session + per-component). Broader than reachable-transport-descriptor.json: additionally allows 'local' (never persisted in reachability.routes; local reachability is derived from the current process's session identity) and 'user-computer-profile' (a targeted executor selection, optionally carrying a trusted 'force-route' override). This union is NEVER reused as reachability.routes[*].descriptor.",
  "type": "object",
  "required": ["type"],
  "oneOf": [
    {
      "description": "The local executor (this process). Never valid as a persisted reachability route.",
      "properties": { "type": { "const": "local" } },
      "required": ["type"],
      "unevaluatedProperties": false
    },
    {
      "description": "A named remote user-computer-profile target, resolved through precedence (a)-(e). 'force-route', when present, is honored only for an authorized trusted caller (precedence step d) and is itself a closed reachable-transport-descriptor (http | reverse-http) - exactly one level of nesting, never another 'user-computer-profile' or 'local', so there is no recursive ambiguity.",
      "properties": {
        "type": { "const": "user-computer-profile" },
        "entity-id": { "$ref": "core.json#/$defs/entity-id" },
        "force-route": { "$ref": "reachable-transport-descriptor.json" }
      },
      "required": ["type", "entity-id"],
      "unevaluatedProperties": false
    },
    { "$ref": "reachable-transport-descriptor.json#/$defs/http-descriptor" },
    { "$ref": "reachable-transport-descriptor.json#/$defs/reverse-http-descriptor" }
  ]
}

The http/reverse-http branches are referenced by JSON Pointer into
reachable-transport-descriptor.json's named $defs (the same idiom already used for
core.json#/$defs/entity-id throughout this codebase), so the field shape is defined exactly once
and both unions stay in lock-step without duplication.

agent-session.json re-point (replacing the open additionalProperties: true shape)

"executor-bindings": {
  "type": "object",
  "description": "The resolved executors for this session, so the topology reconstructs on resume. Every value is an executor-connection-descriptor.json object.",
  "properties": {
    "session": {
      "$ref": "executor-connection-descriptor.json",
      "description": "The explicit overall session executor (default {\"type\":\"local\"}); per-component executors override it."
    },
    "components": {
      "type": "object",
      "additionalProperties": { "$ref": "executor-connection-descriptor.json" },
      "description": "Per-executor map of executor name -> resolved executor-connection-descriptor.json object."
    }
  },
  "unevaluatedProperties": false
}

Removed from this design: the not-based secret-key denylist as a stand-in for validation, and
the claim that "extension types are accepted with only a type discriminator." Strict allowlisting
makes the denylist redundant for structural fields — authorization, cookie, headers,
*-token, etc. are never declared properties of any closed branch, so they are rejected by
unevaluatedProperties: false the same as any other unknown key, with no need to enumerate them.
What strict allowlisting does not catch is a credential smuggled inside an otherwise
syntactically valid URL's userinfo, query string, or fragment (e.g.
https://user:token@host/, https://host/?access_token=..., https://host/#token=...). The
userinfo case is rejected by the schema-level pattern guard above as defense-in-depth; rejecting
secret-shaped query/fragment content is not attempted in JSON Schema (a regex over arbitrary
query param names is both incomplete and easy to route around) and is instead an explicit
application-layer responsibility of the route/URL normalization step described in Security /
Authorization below.

Versioning / Compatibility

Persisting a closed oneOf union is a deliberate, permanent trade-off against ad hoc plugin
extensibility, made explicit here:

  • Adding a transport kind is a schema-and-code change. A new persisted transport (e.g. a future
    QUIC or named-pipe route) requires: (1) a new $defs branch added to
    reachable-transport-descriptor.json's oneOf (and, if it should also be selectable transiently,
    to executor-connection-descriptor.json's oneOf), and (2) corresponding
    ITransportFactory/route-store/publisher code. There is no runtime registration path that lets an
    unrecognized type validate.
  • Old readers fail closed, not open. A reader running an older schema version that encounters a
    route whose type it does not recognize (written by a newer publisher after a schema bump)
    rejects/ignores that specific route rather than attempting to execute an unknown transport. This is
    the opposite of the prior open design, where an unrecognized type would validate and could
    silently be dispatched to a generic/no-op handler.
  • Rollout coordination. Because old readers reject unknown route shapes at the schema level (not
    merely at the router's dispatch level), a route writer must not publish a new descriptor kind until
    the minimum supported reader version in the deployment accepts the updated schema (composed schema
    version bump), or must gate the new kind behind a feature/version flag until rollout completes.
    This is a stronger compatibility requirement than the previous open design had, and is the direct,
    accepted cost of removing the plugin extension point.

Route Publication Lifecycle

Direct receive-only HTTP listener (Machine D).

  1. Publishes its direct-http slot only after the listener binds and is ready (never advertises
    a dead endpoint).
  2. Renews last-confirmed/expires-at on a lease interval shorter than the lease (e.g. renew at
    half the lease duration); the store rejects a renewal whose computed expires-at would not be
    strictly after last-confirmed.
  3. On graceful shutdown, removes only its direct-http slot.
  4. On crash, the slot self-heals: readers ignore it once expires-at passes.

Reverse-HTTP client, per hub (Machines B, C).

  1. After a successful authenticated registration with a hub, upserts one
    reverse-http:<hub-profile-entity-id> slot carrying the current hub-urls and a fresh lease.
  2. Reconnect / devtunnel URL rotation updates the same slot in place (URL + lease); other hubs'
    slots are untouched, so multiple hubs coexist.
  3. On disconnect from a hub, removes only that hub's slot.
  4. Startup crash-recovery clears only slots whose owner-profile-entity-id is this profile
    (owner-scoped), never other publishers' slots, then re-publishes as registrations succeed.

Live registry is authoritative while connected. Hub A's live
ReverseHttpServerTransportFactory.registrations are used for resolution before and independent of
DAL persistence
. A registration that is live but whose route persistence failed still relays via
the live registry (resolution path b); it merely is not discoverable by other processes via the
persisted route (path c). That divergence must be surfaced/observable (log + status), but an
already-live local registration keeps working locally.

Concurrency. Each publisher mutates exactly one route key via a targeted patch
(replace/remove at /reachability/routes/<route-id>), not a whole-object rewrite, so independent
hub and direct publishers do not clobber each other. Optimistic-concurrency conflicts (entity
change-id) are resolved by re-reading and re-applying the single-slot patch; because writers touch
disjoint keys, conflicts are rare and always converge.

Resolution Algorithm

UserComputerProfileTransportFactory.ConnectToAsync resolves a
{"type":"user-computer-profile","entity-id":<target>} in this explicit precedence:

a. Local target → local transport. If <target> is this process's own profile id, route to
LocalTransportFactory. (Highest precedence; cheapest and unambiguous.)

b. Live inbound registration → direct local relay. Else if the process's shared
ReverseHttpServerTransportFactory holds a live authenticated registration for <target>, relay
through that registered channel, preserving the registration's authenticated peer identity. Used
without waiting for DAL persistence. (This is Gap 2's fix.)

c. Valid persisted routes → deterministic attempt/race. Else read the target profile's
reachability.routes, drop expired slots (expires-at in the past), and order the remainder
deterministically by (priority asc, type-rank, route-id lexical). Attempt in that order with
fallback; a reverse-http descriptor internally races its own hub-urls per existing semantics.

d. Trusted transient override. Else, if the request carries a transient
executor-connection-descriptor.json-shaped user-computer-profile binding with a force-route
(or a bare transient http/reverse-http descriptor) and the caller is an authorized trusted
caller
(explicit trusted executor / advanced manifest / test harness), route through it.

e. Otherwise → unavailable error. Throw the existing "no route" error. Never silently fall back
to local execution
on the initiating machine for a non-local target.

Why transient override is placed after the live registry and persisted routes (safety choice).
Honoring a caller-supplied descriptor ahead of the target's own authoritative reachability is an
SSRF / route-hijack vector. Therefore a transient descriptor (d) is (1) accepted only from an
authorized trusted caller, (2) always schema-validated against the closed
executor-connection-descriptor.json (never an ad hoc object), and (3) ordered after local (a), the
live registry (b), and the target's published routes (c). A trusted caller that must force a specific
endpoint (advanced manifest / testing) sets an explicit force-route on its user-computer-profile
binding, which moves the override ahead of persisted routes (c) but never ahead of local (a) or a live
authenticated local registration (b). Untrusted callers can never preempt authoritative reachability.

Security / Authorization

  • Reachability is not authorization. A published route only says how to reach a profile, never
    who may. Every relay (path b) and every direct dial (path c) still authenticates the caller and
    authorizes the target/action independently. Path b reuses the registration's authenticated peer
    identity; unauthenticated/unknown callers are refused before any relay is established.
  • Credential-free persistence via strict allowlisting, not a denylist. Every closed branch of
    reachable-transport-descriptor.json declares exactly the fields a transport needs
    (type+url, or type+hub-urls+entity-id); unevaluatedProperties: false rejects anything
    else, including authorization/cookie/headers/*-token keys, without needing to enumerate
    them. This replaces the previous not-denylist design, which only rejected a fixed, incomplete
    list of known secret-key names and would have silently accepted a differently-named secret field.
    Publishers additionally normalize and redact endpoint URLs (strip userinfo/secret query
    params) before writing, and readers/route stores reject a URL containing userinfo or an
    obviously credential-shaped query/fragment at the application layer — this is not, and cannot
    reliably be, a JSON Schema concern for query/fragment content (see the "Removed from this design"
    paragraph above).
  • URL validation / SSRF. Endpoint URLs are validated against a scheme allowlist (https
    generally; http only for loopback/private ranges per policy), normalized, and checked for SSRF /
    untrusted-redirect implications before use.
  • Owner-only mutation. Only the owning profile/process (owner-profile-entity-id == self) or an
    authorized registration service may create/replace/remove a given route slot. Readers ignore slots
    whose owner-profile-entity-id does not match the expected publisher. For a reverse-http slot,
    the route store additionally verifies at the application layer that the descriptor's entity-id
    matches the route's own owner-profile-entity-id and the profile being published to; a mismatch
    (one profile's route naming another profile's entity-id) is rejected before the patch is applied
    — JSON Schema alone cannot express this cross-field/cross-entity equality.

Affected Files

File Role in this change
Phantom.Workspaces.Data.Core/JsonSchemas/user-computer-profile.json Add the top-level reachability object property (currently absent), closed to routes only.
Phantom.Workspaces.Data.Core/JsonSchemas/reachable-transport-descriptor.json New. Closed, persisted-only transport union (http | reverse-http).
Phantom.Workspaces.Data.Core/JsonSchemas/reachability-route.json New. Closed per-route slot schema.
Phantom.Workspaces.Data.Core/JsonSchemas/executor-connection-descriptor.json New. Closed, broader transient executor union (local | user-computer-profile (+ optional force-route) | http | reverse-http), sharing the http/reverse-http branches with reachable-transport-descriptor.json by $defs reference.
Phantom.Workspaces.Data.Core/JsonSchemas/agent-session.json Re-point executor-bindings.session and executor-bindings.components.* at executor-connection-descriptor.json (was inline additionalProperties:true).
Phantom.Workspaces.Transport/UserComputerProfileTransportFactory.cs Implement precedence a–e; consult the shared live inbound registry (b) and the persisted reachability.routes (c); keep the exact "no route" throw for (e).
Phantom.Workspaces/Services/WorkspacesTransportComposition.cs Own/expose a single shared ReverseHttpServerTransportFactory; inject the reachability route store + live registry into the router.
Phantom.Workspaces/Services/WorkspacesWebHost.cs Use the shared ReverseHttpServerTransportFactory instead of a private one.
Phantom.Workspaces.Transport/ReverseHttp/ReverseHttpServerTransportFactory.cs Live relay to a registered target preserving authenticated peer identity; refuse unknown/unauthenticated targets.
Phantom.Workspaces.Transport/ReverseHttp/ReverseHttpClientTransportFactory.cs Reverse publisher: upsert/renew/remove its per-hub reverse-http:<hub-id> route slot (owner-scoped).
Phantom.Workspaces.Transport/ReverseHttp/ReverseHttpForwardingTransportFactory.cs Client-side dial of hub-urls from a persisted reverse-http route (path c).
(direct HTTP listener host) Phantom.Workspaces/Services/WorkspacesWebHost.cs / listener wiring Direct publisher: publish/renew/remove the direct-http route after bind/readiness.
Phantom.Workspaces/ViewModels/MainWindowViewModel.cs Wire the shared inbound registry + route store into composition for all repository sources (not Web-only).
new IReachabilityRouteStore (Transport or Data.Core) Single-slot upsert/renew/remove with optimistic concurrency, lease, owner guard, expires-at > last-confirmed enforcement, URL normalization/redaction, and reverse-descriptor entity-id-vs-owner cross-check.
docs/design/unified-transport-layer.md Supersede the sections describing a single persisted connection-descriptor with an inline hub-urls field; replace with the reachability.routes map model and the strict schema split defined here.

Design / Fix

  1. Add the shared strict schemas (reachable-transport-descriptor.json,
    reachability-route.json, executor-connection-descriptor.json) and the reachability property
    on user-computer-profile.json; re-point agent-session.json executor-bindings at
    executor-connection-descriptor.json. Validate under the composed schema; add the full schema
    regression suite below (acceptance + every rejection case).
  2. Add a route store (IReachabilityRouteStore) that performs owner-guarded, single-slot
    upsert/renew/remove patches with optimistic concurrency, lease stamping,
    expires-at > last-confirmed enforcement, URL normalization/redaction, and reverse-descriptor
    entity-id cross-checks.
  3. Share one inbound registry between WorkspacesTransportComposition and WorkspacesWebHost,
    and inject both the registry and the route store into UserComputerProfileTransportFactory.
  4. Implement precedence a–e in the router, including the local-inbound relay (b), the persisted
    route attempt/race (c), the trusted-only transient override (d), and the no-local-fallback error
    (e).
  5. Publishers: the direct HTTP listener publishes/renews/removes direct-http; each reverse
    client publishes/renews/removes reverse-http:<hub-id>; both are owner-scoped and lease-refreshed.
  6. Do not persist any secret; redact URLs; keep the live registry authoritative while connected.

This is a data-model addition, not a route-only patch: the earlier revision's "no schema / no
persistence" constraint is explicitly superseded by design goal "Design a data model into 1593,"
and this revision further tightens that data model to be strict end-to-end.

Expected Tests

All persisted user-computer-profile entities are seeded through the composed, schema-validated path
(the #1590 ValidatingEntitySeedFixture pattern) — never a raw/fake DAL and never a hand-written
reachability blob that bypasses validation. Integration rows drive the real production composition,
transport factories, and reverse-HTTP seams (WorkspacesTransportComposition, WorkspacesWebHost,
ReverseHttpServerTransportFactory, ReverseHttpForwardingTransportFactory,
UserComputerProfileTransportFactory); an in-memory network/channel harness is acceptable for I/O,
but a mock that only records the requested descriptor is not sufficient for the integration rows.

Schema / data-model regression (Subject_Scenario_ExpectedOutcome, all schema-aware per #1590)

Test Verifies
UserComputerProfileSchema_ValidDirectHttpRoute_IsAccepted An exact, well-formed direct-http route entry validates and round-trips through the composed schema.
UserComputerProfileSchema_ValidReverseHttpRoute_IsAccepted An exact, well-formed reverse-http:<uuid> route entry validates and round-trips through the composed schema.
UserComputerProfileSchema_ReachabilityWithZeroRoutes_IsAccepted reachability: { "routes": {} } (zero entries) is valid — the self-heal state after a lease expires and before republishing.
ReachableTransportDescriptorSchema_UnknownType_IsRejected A descriptor with an unrecognized type (e.g. "quic") is rejected — no open/plugin extension point.
ReachableTransportDescriptorSchema_LocalTypePersisted_IsRejected A route descriptor with type: "local" is rejected; local is only valid in executor-connection-descriptor.json, never persisted reachability.
ReachableTransportDescriptorSchema_HttpMissingUrl_IsRejected An http descriptor missing url is rejected.
ReachableTransportDescriptorSchema_ReverseHttpMissingHubUrlsOrEntityId_IsRejected A reverse-http descriptor missing hub-urls or entity-id is rejected.
ReachableTransportDescriptorSchema_ExtraPropertyOnAnyBranch_IsRejected An extra, non-declared property on an http or reverse-http branch (e.g. authorization, headers, or an arbitrary unrelated key) is rejected by unevaluatedProperties: false — no denylist needed.
ReachableTransportDescriptorSchema_CredentialBearingUrlUserinfo_IsRejected A url/hub-urls entry containing user:pass@host userinfo is rejected by the schema-level pattern guard.
ReachableTransportDescriptorSchema_DuplicateHubUrl_IsRejected A reverse-http descriptor with a duplicate entry in hub-urls is rejected (uniqueItems).
ReachableTransportDescriptorSchema_HubUrlsExceedsMax_IsRejected A reverse-http descriptor with more than 8 hub-urls entries is rejected.
ReachabilityRouteSchema_MissingRequiredLifecycleField_IsRejected A route missing any of descriptor/owner-profile-entity-id/last-confirmed/expires-at is rejected, one case per field.
ReachabilityRouteSchema_ExtraProperty_IsRejected A route entry with an extra, non-declared property is rejected.
ReachabilityRouteSchema_MalformedTimestamp_IsRejected A non-date-time last-confirmed/expires-at value is rejected.
ReachabilityRouteSchema_MalformedOwnerUuid_IsRejected A non-UUID owner-profile-entity-id is rejected.
ReachabilityRouteSchema_PriorityOutOfRange_IsRejected A priority below 0 or above 1000 is rejected.
UserComputerProfileSchema_MalformedRouteId_IsRejected A route id that is neither direct-http nor reverse-http:<uuid> (e.g. "reverse-http:not-a-uuid", "", "direct-https") is rejected via propertyNames.
UserComputerProfileSchema_ReachabilityMissingRoutesKey_IsRejected reachability: {} (present without its only allowed routes key) is rejected — distinct from the zero-routes case above, which requires the key to be present with an empty map.
UserComputerProfileSchema_RouteCountExceedsMaximum_IsRejected A routes map with more than 16 entries is rejected.
ExecutorConnectionDescriptorSchema_Local_IsAccepted {"type":"local"} validates for executor-bindings but is never accepted for reachability.routes[*].descriptor.
ExecutorConnectionDescriptorSchema_UserComputerProfileWithForceRoute_IsAccepted A user-computer-profile executor binding with a nested force-route (http or reverse-http) validates; a force-route of type: "local" or type: "user-computer-profile" is rejected (no recursive ambiguity).
ExecutorConnectionDescriptorSchema_SharesHttpReverseHttpBranchesWithReachability The http/reverse-http branches validate identically whether reached via reachable-transport-descriptor.json or executor-connection-descriptor.json (same shared $defs).

Route store / publisher lifecycle

Test Verifies
ReachabilityRouteStore_DirectListenerPublishesAfterBind_UpsertsOwnedDirectHttpSlot The direct listener publishes direct-http only after readiness, owned by itself, with a future expires-at.
ReachabilityRouteStore_ReverseClientRegistersWithHub_UpsertsPerHubSlot A reverse client upserts exactly one reverse-http:<hub-id> slot on successful authenticated registration.
ReachabilityRouteStore_ReconnectRotatesUrl_UpdatesSameSlotInPlace Reconnect/URL rotation updates the same slot's hub-urls/lease without adding a second slot.
ReachabilityRouteStore_GracefulShutdown_RemovesOnlyOwnedSlot Graceful shutdown removes only the publisher's own slot, leaving other publishers' slots intact.
ReachabilityRouteStore_ConcurrentDirectAndHubPublishers_DoNotClobberEachOther Independent direct and hub publishers writing concurrently each retain their own slot under optimistic-concurrency retries.
ReachabilityRouteStore_StartupCrashRecovery_ClearsOnlyOwnerScopedStaleSlots Startup clears only slots owned by this profile, never other publishers' slots.
ReachabilityRouteStore_RoutePersistFailsButRegistrationLive_SurfacesAndKeepsLocalReachability A persist failure is surfaced/observable while the live registration still relays locally.
ReachabilityRouteStore_ExpiresAtNotAfterLastConfirmed_IsRejectedAtWriteTime A write with expires-at <= last-confirmed is rejected by the store before the patch is applied (JSON Schema cannot express this cross-field ordering).
ReachabilityRouteStore_ReverseDescriptorEntityIdMismatchesOwner_IsRejectedAtWriteTime A reverse-http descriptor whose entity-id does not match the route's owner-profile-entity-id/target profile is rejected before the patch is applied.
ReachabilityRouteStore_UrlWithCredentialQueryOrFragment_IsRejectedAtWriteTime A URL containing an obviously credential-shaped query string or fragment (e.g. ?access_token=, #token=) is normalized/rejected by the application layer even though it would pass the schema's userinfo-only pattern guard.

Resolver precedence (component-level)

Test Verifies
UserComputerProfileTransportFactory_LocalTarget_RoutesLocal A local target routes to LocalTransportFactory (precedence a).
UserComputerProfileTransportFactory_TargetInLiveInboundRegistry_RoutesThroughLocalRelay With an empty hub config but a live registration, the router relays locally (precedence b) — the original Gap-2 fix.
UserComputerProfileTransportFactory_TargetLiveRegistered_PrefersLiveRelayOverPersistedRoute When both a live registration and a persisted route exist, the live relay wins (b before c).
UserComputerProfileTransportFactory_MultiplePersistedRoutes_AttemptsByPriorityThenType Routes are attempted deterministically by (priority, type-rank, route-id) with fallback (precedence c).
UserComputerProfileTransportFactory_ExpiredPersistedRoute_IsIgnored An expired route is skipped; a coexisting unexpired route is used.
UserComputerProfileTransportFactory_UntrustedTransientDescriptor_DoesNotPreemptAuthoritativeRoutes An untrusted transient descriptor never preempts local/live/persisted routes.
UserComputerProfileTransportFactory_TrustedForceRouteOverride_PrecedesPersistedButNotLive A trusted force-route override precedes persisted routes (c) but not local (a) or live registry (b).
UserComputerProfileTransportFactory_NoLocalNoLiveNoRouteNoOverride_ThrowsNoRoute The exact "no transient route and no configured reverse HTTP hub" error still throws (precedence e); no silent local fallback.
WorkspacesTransportComposition_InboundRegistrySharedWithWebHost_RouterObservesInboundRegistrations Composition and web host share one ReverseHttpServerTransportFactory; inbound registrations are visible to the router.
MainWindowReverseHttpRegistration_NonWebSourceDaemon_WiresSharedRegistryAndRouteStore A non-Web daemon with RemoteHosting.Enabled wires the shared registry + route store into the router.

Multi-machine integration acceptance gate (mandatory to close)

Stand up independent process/composition roots A (hub/daemon owning the shared schema-validated
DAL and the shared reverse-HTTP registry), B and C (reverse clients that authenticate against
A and hold long-lived registrations), and D (a direct receive-only HTTP listener). All profiles
are schema-validated persisted entities.

Test Verifies
AgentStackMultiInstance_HubInvokesRegisteredClient_RoutesThroughLiveInboundRegistry A→C: A resolves C's bare profile via its own live inbound registry (b); response returns to A; B's registration untouched.
AgentStackMultiInstance_ClientInvokesPeerViaPersistedReverseRoute_RelaysThroughHub B→C: B (never dialing C directly) reads C's persisted reverse-http route, dials hub A; A authenticates B, resolves C in its registry, relays; response returns to B.
AgentStackMultiInstance_ClientInvokesDirectListener_UsesPersistedDirectHttpRoute B→D (and A→D): the caller uses D's persisted direct-http route to reach D directly; D executes locally; response returns to the caller.
AgentStackMultiInstance_TargetHasMultipleRoutes_PrefersHigherPriorityWithFallback A target advertising a direct route and a hub route is reached by the preferred route; on preferred-route failure the caller falls back to the next.
AgentStackMultiInstance_ExpiredPersistedRoute_IsNotUsed A caller ignores an expired persisted route and errors (or falls back) rather than dialing a dead endpoint.
AgentStackMultiInstance_ClientReconnects_PersistedRouteUpdatedAndReachable After C reconnects (new URL/lease), subsequent B→C relays reach only the current live channel/route.
AgentStackMultiInstance_TargetDisconnectsDuringRelay_PropagatesTransportFailure C's channel drops mid-relay; the failure propagates to the original caller as an error (no hang); A's registry no longer lists C.
AgentStackMultiInstance_LeaseExpiresWhileConnected_LiveRegistryStillServes With a live registration but an expired persisted lease, path (b) still serves locally while path (c) is skipped.
AgentStackMultiInstance_ConcurrentPublishersAndCallers_PreserveOwnershipAndIdentity Concurrent A→C and B→C plus concurrent direct/hub publishing keep each response with its originating caller and never conflate C's route/identity with B's.
AgentStackMultiInstance_UnauthorizedClientInvokesPeer_IsRejected An unauthenticated caller invoking C through A is rejected before any relay; C observes no inbound request.
AgentStackMultiInstance_UnregisteredUnpublishedTarget_DoesNotFallBackToLocalExecution A profile with no live registration, no persisted route, and no trusted override throws the "no route" error and does not execute locally on A.
AgentStackMultiInstance_CallerCancelsPeerExecution_CancelsRelay The originating caller cancels an in-flight relayed execution; cancellation propagates through A's relay to the target, which observes cancellation.

Implementation Plan

Each commit is bounded; dependencies noted.

  1. Shared strict schema/model — add reachable-transport-descriptor.json +
    reachability-route.json + executor-connection-descriptor.json, add reachability to
    user-computer-profile.json, re-point agent-session.json executor-bindings at
    executor-connection-descriptor.json; the full schema/data-model regression suite above
    (acceptance + every rejection case, at every object layer). (no dependency)
  2. Route publisher store + lifecycle — IReachabilityRouteStore: owner-guarded single-slot
    upsert/renew/remove with optimistic concurrency + lease, expires-at > last-confirmed
    enforcement, URL normalization/redaction, reverse-descriptor entity-id cross-check;
    store/lifecycle tests. (depends on 1)
  3. Resolver precedence + local-registry integration — precedence a–e in
    UserComputerProfileTransportFactory; share one ReverseHttpServerTransportFactory across
    composition + web host; consult the route store; no-local-fallback negative. Incorporates the
    original Gap-2 local-inbound fix. (depends on 2)
  4. Direct HTTP publisher — publish/renew/remove direct-http on bind/readiness/shutdown.
    (depends on 2)
  5. Reverse publisher — per-hub reverse-http:<hub-id> publish/renew/remove; startup
    owner-scoped cleanup. (depends on 2)
  6. A/B/C/D integration acceptance suite — the multi-machine gate above. (depends on 3, 4, 5)

Considered / Background

  • Superseding the earlier "never persist" revision. A prior revision of this issue argued the
    fix must be route-only with no schema change, because 508cbc04 removed reading of a persisted
    connection-descriptor. This revision supersedes that stance per the explicit design goal to
    design a data model into Design and persist a canonical routing/reachability data model on user-computer-profile (local inbound registry + reverse-hub + direct HTTP routes) #1593. What is retained from that revision, and from Bug: Tests seed supposedly-valid persisted entities through raw/fake DALs, bypassing schema validation (encodes unstorable fixtures) #1590, is that any
    persisted entity must validate through the composed schema — hence a declared reachability
    property rather than a resurrected undeclared connection-descriptor field.
  • Single connection-descriptor vs. array vs. map. Rejected the single object (no multi-route,
    clobbering) and the array (no stable identity, concurrent-writer races); chose a map keyed by stable
    route id (publisher-owned slots, idempotent upsert, per-slot lease/ownership). See Canonical Data
    Model.
  • Separate reachability entities/relationships. A reachability-route entity per slot linked to
    the profile would give stronger concurrency isolation and natural history. Deferred: the canonical
    in-profile map with per-slot patches already isolates independent publishers, and a separate-entity
    model adds relationship/lifecycle overhead not justified for a bounded, publisher-owned slot set. It
    remains the migration path if per-route history/audit is later required.
  • Open descriptor + secret denylist (an earlier draft of this very issue): rejected. An earlier
    draft left the descriptor open ({ "type": ... } plus arbitrary extra fields) so unregistered
    plugin descriptor types would "remain minimally valid," and relied on a not-based denylist of
    known secret-key names as the credential guard. Both are removed in this revision: an open
    descriptor lets any caller/writer persist an unrecognized transport that some reader might
    mis-handle, and a denylist only ever blocks the secret-key names someone thought to enumerate.
    Strict allowlisting (closed oneOf, unevaluatedProperties: false at every layer) is a strictly
    stronger guarantee at the one-time cost of the versioning/rollout discipline described above.
  • Design-doc alignment. docs/design/unified-transport-layer.md currently describes a single
    persisted connection-descriptor with an inline hub-urls list on the profile. Those sections are
    superseded by the strict reachability.routes map here and must be updated; do not claim the
    current schema already supports any routing property — it does not until commit 1 lands.
  • Live registry vs. persistence. The hub's live inbound registry is authoritative while a client
    is connected and is used before/independent of DAL persistence; persisted routes exist for peers and
    cross-process discovery. A route persist failure is surfaced but does not disable an already-live
    local registration.

Related Issues

Activity

  1. changed the title [-]Reverse-HTTP client registration never persists connection-descriptor; UserComputerProfileTransportFactory throws for authenticated remote profiles[/-] [+]UserComputerProfileTransportFactory cannot route to a profile registered in the host's own inbound reverse-HTTP registry (only consults outbound hub URLs)[/+] on Sep 18, 2026
  2. changed the title [-]UserComputerProfileTransportFactory cannot route to a profile registered in the host's own inbound reverse-HTTP registry (only consults outbound hub URLs)[/-] [+]Design and persist a canonical routing/reachability data model on user-computer-profile (local inbound registry + reverse-hub + direct HTTP routes)[/+] on Sep 18, 2026
  3. JoshuaRowePhantom commented on Sep 18, 2026

    @JoshuaRowePhantom
    OwnerAuthor

    Design

    Root cause

    UserComputerProfileTransportFactory can currently route a remote profile only from caller-supplied transient metadata or the caller process's outbound hub URLs. The target profile has no schema-declared durable reachability data, and the reverse-HTTP server used by WorkspacesWebHost is a private instance that the profile router cannot observe. Consequently a hub cannot route to its own authenticated inbound registration, peers cannot discover a target's hub/direct endpoint, and independent publishers cannot safely coexist.

    Approach

    1. Add closed Draft 2020-12 schemas for persisted http/reverse-http descriptors, route slots, and transient executor descriptors; add user-computer-profile.reachability.routes; point agent-session executor bindings at the closed transient schema.
    2. Add a DAL-backed route store using owner-checked, single-slot JSON Patch updates with optimistic-concurrency retry, deterministic leases, strict lifecycle/cross-field checks, and credential-bearing/unsafe URL rejection.
    3. Share one ReverseHttpServerTransportFactory between production composition and WorkspacesWebHost. Expose a direct live-registration relay path, then resolve profile targets in local → live registration → trusted forced route (when explicitly forced) / persisted route ordering → trusted legacy transient/configured-hub compatibility → unavailable order. Persisted routes are filtered by owner/expiry and attempted deterministically with fallback.
    4. Wire direct-listener and reverse-client publication/removal into host lifecycle. Renewal will use cancellable TimeProvider-based scheduling, never sleeps/polling, and persistence failures will remain observable without breaking a live registration.
    5. Replace transient-route test fixtures with composed-schema persisted profiles and add deterministic schema, store/lifecycle, precedence, composition, and A/B/C/D transport integration coverage. Update the unified transport design.

    Alternatives considered

    A single descriptor was rejected because publishers clobber one another; an array was rejected because it lacks stable slot identity; separate route entities were deferred as unnecessary for the bounded map. An open descriptor/secret denylist was rejected in favor of closed allowlisted unions plus application-layer URL policy. Reusing a private web-host registry was rejected because it cannot satisfy local live-relay precedence.

    Expected files

    • Phantom.Workspaces.Data.Core/JsonSchemas/{user-computer-profile,agent-session,reachable-transport-descriptor,reachability-route,executor-connection-descriptor}.json
    • New route model/store files under Phantom.Workspaces.Transport
    • UserComputerProfileTransportFactory and reverse-HTTP client/server/forwarding factories
    • WorkspacesTransportComposition, WorkspacesTransportHost, WorkspacesWebHost, and MainWindowViewModel
    • Corresponding Data.Core, Transport, and application integration tests
    • docs/design/unified-transport-layer.md
  4. JoshuaRowePhantom commented on Sep 21, 2026

    @JoshuaRowePhantom
    OwnerAuthor

    Implementation notes

    Commits: d12c5540991386ee0855c18b39c1e91e4ff3fa86

    Deviations from proposed design

    The implementation follows the proposed schema, route-store, precedence, publication, and integration design. It additionally adds a reverse-registration-info control frame so reverse clients persist the actual stable hub profile entity id rather than deriving an id from a mutable URL.

    Scope changes

    • Direct HTTP publication follows the dev-tunnel public endpoint while hosting and returns to the bound local endpoint when the tunnel is unavailable.
    • The reverse relay now namespaces multiplexed correlation ids per caller, preserves authenticated peer identity, and deterministically closes hosted sessions when a registration drops; these were required by the concurrent/cancellation acceptance rows.
    • Trusted transient routes are disabled by default and enabled only by the trusted production composition; untrusted callers cannot opt in through descriptor data.

    Files changed

    • Phantom.Workspaces.Data.Core.Tests/ReachabilitySchemaTests.cs
    • Phantom.Workspaces.Data.Core/JsonEntities/schema-definitions/executor-connection-descriptor-json-schema.json
    • Phantom.Workspaces.Data.Core/JsonEntities/schema-definitions/reachability-route-json-schema.json
    • Phantom.Workspaces.Data.Core/JsonEntities/schema-definitions/reachable-transport-descriptor-json-schema.json
    • Phantom.Workspaces.Data.Core/JsonSchemas/agent-session.json
    • Phantom.Workspaces.Data.Core/JsonSchemas/executor-connection-descriptor.json
    • Phantom.Workspaces.Data.Core/JsonSchemas/reachability-route.json
    • Phantom.Workspaces.Data.Core/JsonSchemas/reachable-transport-descriptor.json
    • Phantom.Workspaces.Data.Core/JsonSchemas/user-computer-profile.json
    • Phantom.Workspaces.Tests/WorkspacesTransportCompositionTests.cs
    • Phantom.Workspaces.Tests/WorkspacesTransportHostTests.cs
    • Phantom.Workspaces.Tests/WorkspacesWebHostTests.cs
    • Phantom.Workspaces.Transport.Tests/Infrastructure/HubRelayHarness.cs
    • Phantom.Workspaces.Transport.Tests/Infrastructure/InProcessReverseHubFixture.cs
    • Phantom.Workspaces.Transport.Tests/ReachabilityRouteStoreTests.cs
    • Phantom.Workspaces.Transport.Tests/ReverseHttpServerTransportFactoryTests.cs
    • Phantom.Workspaces.Transport.Tests/Scenarios/AgentStackCrossInstanceTests.cs
    • Phantom.Workspaces.Transport.Tests/Scenarios/AgentStackMultiInstanceReachabilityTests.cs
    • Phantom.Workspaces.Transport.Tests/UserComputerProfileTransportFactoryTests.cs
    • Phantom.Workspaces.Transport/DataAccessReachabilityRouteStore.cs
    • Phantom.Workspaces.Transport/IReachabilityRouteStore.cs
    • Phantom.Workspaces.Transport/ReachabilityRoute.cs
    • Phantom.Workspaces.Transport/ReachabilityRouteLease.cs
    • Phantom.Workspaces.Transport/ReachabilityRouteStoreException.cs
    • Phantom.Workspaces.Transport/ReverseHttp/ReverseExecutionDispatcher.cs
    • Phantom.Workspaces.Transport/ReverseHttp/ReverseHttpClientTransportFactory.cs
    • Phantom.Workspaces.Transport/ReverseHttp/ReverseHttpForwardingTransportFactory.cs
    • Phantom.Workspaces.Transport/ReverseHttp/ReverseHttpServerTransportFactory.cs
    • Phantom.Workspaces.Transport/ReverseHttp/ReverseHttpTransport.cs
    • Phantom.Workspaces.Transport/UserComputerProfileTransportFactory.cs
    • Phantom.Workspaces.Web.Server/Program.cs
    • Phantom.Workspaces/Services/WorkspacesTransportComposition.cs
    • Phantom.Workspaces/Services/WorkspacesTransportHost.cs
    • Phantom.Workspaces/Services/WorkspacesWebHost.cs
    • Phantom.Workspaces/ViewModels/MainWindowViewModel.cs
    • docs/design/unified-transport-layer.md
  5. JoshuaRowePhantom commented on Sep 21, 2026

    @JoshuaRowePhantom
    OwnerAuthor

    Verification result

    Status: ✅ Verified

    Branch: fix/1593-profile-reachability-routes
    Commits checked (features..HEAD, base 7a255d72):

    d12c5540 Fix #1593: persist profile reachability routes
    

    This verification was performed independently and read-only: the issue body + both prior comments were re-read, acceptance criteria re-derived, and the full diff (4276+/184-, 36 files) inspected directly rather than trusting the implementation notes. Every persisted entity in tests goes through the composed, schema-validated seed path (ValidatingEntitySeedFixture, #1590 rule).

    Acceptance criteria — all satisfied

    • Schemas (strict, closed). reachable-transport-descriptor.json (closed oneOf http|reverse-http, unevaluatedProperties:false per branch, userinfo pattern guard, hub-urls min/max/unique), reachability-route.json (5 closed fields, priority 0–1000), executor-connection-descriptor.json (local|user-computer-profile+one-level force-route|http|reverse-http, shared $defs), user-computer-profile.reachability.routes (propertyNames route-id pattern, 0–16, required:[routes]), agent-session.executor-bindings re-pointed open→closed. No root unevaluatedProperties on entity-type files; nested scopes self-close. 3 schema-definition entities added (unique entity-ids, auto-embedded). All 22 schema acceptance/rejection tests present + green.
    • Route store / leases. IReachabilityRouteStore/DataAccessReachabilityRouteStore: owner==target guard, single-slot JSON-Patch upsert/replace/remove with optimistic-concurrency retry, lease stamping, expires-at > last-confirmed enforced at write time, URL normalization/redaction (userinfo + credential query/fragment + http restricted to loopback/private = SSRF policy), reverse-descriptor entity-id cross-check, owner-scoped ClearOwnedRoutes. ReachabilityRouteLease renews at half-lease via PeriodicTimer(TimeProvider). All 10 lifecycle tests green.
    • Resolver precedence a–e. local → live inbound registry → trusted force-route (ahead of persisted) → unexpired persisted routes ordered (priority, type-rank, route-id) with fallback → trusted legacy/configured-hub compat → explicit no-route throw; never silent local fallback. Trusted transient gated (trustedTransientRoutesEnabled default off; enabled only in trusted composition) so untrusted callers can't preempt. All 8 precedence + 2 composition tests green.
    • Reverse HTTP. Shared ReverseHttpServerTransportFactory across composition/web-host/Program.cs; per-caller correlation-id namespacing (no cross-caller conflation/dup delivery), authenticated-peer preservation, requireAuthenticatedRelays refuses unauthenticated relays, deterministic hosted-session close on registration drop; reverse-registration-info frame publishes the stable hub profile id (not URL-derived). No recursion/loop (only http/reverse-http re-dispatched). Leases/relays dispose cleanly.
    • Publishers & wiring. Direct-HTTP publisher publishes/renews/removes direct-http on bind/readiness/shutdown and follows the dev-tunnel endpoint; reverse publisher upserts per-hub reverse-http:<hubId> owner-scoped; startup owner-scoped cleanup. Shared store + registry injected for all repository sources (non-Web daemon covered). Docs superseded.
    • Multi-machine A/B/C/D gate. All 12 integration rows present + green (live-registry relay, persisted reverse/direct routes, priority+fallback, expired-route skip, reconnect, mid-relay disconnect→failure propagation, live-serves-while-lease-expired, concurrent publishers/callers identity preservation, unauthorized rejection, no-local-fallback, cancellation propagation).
    • Determinism. Adversarial tests assert exact outcomes and synchronize via TaskCompletionSource/channels/FakeTimeProvider — no Task.Delay/Thread.Sleep/polling/fixed dispatcher pumping introduced. Pre-existing cross-instance flows (Bug: validator smoke launch of malformed copilot.exe hangs Install.Tests (unsuppressed bad-image hard error; 10-min timeout exceeds 90s blame) #1591/Bug: concurrent Copilot policy envelope creation races launch-root ACL verification #1592-era AgentStackCrossInstanceTests) updated to the closed transient union and still green — no regression. No blocking (.Result/.Wait()/.GetAwaiter().GetResult()) in changed non-test code; no disabled tests; no new TODOs.

    Tests

    Targeted union via scripts/run-tests.ps1 -Mode fast (never direct dotnet test). 131/131 passed; 0 failed. 10/10 consecutive stability runs (131/131 each). No crash dumps.

    Passed!  - Failed: 0, Passed: 37, Skipped: 0, Total: 37 - Phantom.Workspaces.Data.Core.Tests.dll
    Passed!  - Failed: 0, Passed: 28, Skipped: 0, Total: 28 - Phantom.Workspaces.Tests.dll
    Passed!  - Failed: 0, Passed: 66, Skipped: 0, Total: 66 - Phantom.Workspaces.Transport.Tests.dll
    

    Classes: ReachabilitySchemaTests, ReachabilityRouteStoreTests, ReverseHttpServerTransportFactoryTests, ReverseHttpClientTransportFactoryTests, UserComputerProfileTransportFactoryTests, AgentStackMultiInstanceReachabilityTests, AgentStackCrossInstanceTests, WorkspacesTransportCompositionTests, WorkspacesTransportHostTests, WorkspacesWebHostTests.

    needs-slow-tests: No — diff touches no filesystem/Git-repository DAL layer (route store operates over the IDataAccessLayer abstraction; validation exercised by the fast composed-schema path).

    Outstanding gaps

    None — all criteria satisfied.

  6. added a commit that references this issue on Sep 21, 2026
    d12c554
  7. JoshuaRowePhantom commented on Sep 21, 2026

    @JoshuaRowePhantom
    OwnerAuthor

    Checked in

    Commit: d12c554
    Branch fast-forwarded: features → d12c554

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

No labels
No labels

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions