Opsmaru.Incus.Gateway lets a host relay native Incus HTTP through its own
endpoint under its own policy. This page documents the gateway delivered by
add-embeddable-incus-gateway (slices G1–G5): native HTTP, WebSockets and
SFTP/NBD on the host listener, plus operation-scoped migration peer admission.
The capability's requirements are in openspec/specs/incus-gateway/spec.md.
Acceptance evidence (G6) is recorded separately; see "Evidence status" below.
Dependencies
plug and cowboy are optional dependencies of :opsmaru_incus. A host that
mounts the gateway already has them; a client-only consumer does not and still
compiles warning-free (Opsmaru.Incus.Gateway.Plug is simply absent). The
gateway's streaming and abort behaviour is verified with Plug.Cowboy, the
adapter Macus uses.
Host composition
Start an admission process in the host's own supervision tree. The library never starts it.
children = [ {Opsmaru.Incus.Gateway.Admission, name: MyHost.GatewayAdmission, max_exchanges: 64}, MyHostWeb.Endpoint ]Implement
Opsmaru.Incus.Gateway.Host:ready?/1,authorize/2andvalid?/3; provideidentity/2for discovery/server identity andcertificates/5for host-owned trust actions. Missing callbacks deny the corresponding path. Addadmit_operation_secret/2for narrowly admitted migration server peers. These additional callbacks are optional behaviour callbacks for source compatibility; missing callbacks have no permissive fallback.Build the gateway at request time (so runtime configuration and pids are never compiled into the router):
def fetch do Opsmaru.Incus.Gateway.new!( connection: MyHost.Incus.connection(), host: MyHost.GatewayPolicy, host_config: MyHost.GatewayPolicy.config(), admission: MyHost.GatewayAdmission ) endMount it for the native routes, ahead of any body parser:
get "/", Opsmaru.Incus.Gateway.Plug, gateway: {MyHost.Gateway, :fetch, []} forward "/1.0", Opsmaru.Incus.Gateway.Plug, gateway: {MyHost.Gateway, :fetch, []}Keep
/runtime,/mcpand other host routes outside these mounts. A gateway placed behindPlug.Parsersrefuses every request with500instead of relaying a reconstructed body.
Request path
Every request takes the same path before any backend socket opens:
- Route check on the original encoded
request_path: only/,/1.0and/1.0/*; dot segments, NUL and backslashes, raw or percent-encoded, are refused (400). An encoded/inside a segment is kept, since image aliases use it. - Selection parsing on the raw query. Duplicate
project,targetorall-projectsvalues,;separators and malformed escapes are refused (400). The backend parses with Go'surl.ParseQueryand falls back to the default project when the query fails to parse, so the gateway refuses exactly those queries to keep the authorized selection and the executed one identical.all-projectsfollows the backend'sIsTrue. - Admission: at capacity
503, nothing queued. ready?/1, thenauthorize/2, each in a monitored process killed atcallback_timeout. Anything buttrue/{:ok, grant}denies (503/403). An explicit{:error, :unauthenticated}permits only synthetic HTTPGET /,GET /1.0, token enrollment viaPOST /1.0/certificates, or the explicit operation-secret decision documented below. Other denials remain denials. A late answer is discarded. For an authorized grant,valid?/3must return exactlytruebefore backend access.- Protocol: native WebSockets negotiate after authorization when their
optional adapter is available. SFTP/NBD require the same-listener Cowboy
companion below; a direct Plug raw upgrade gets an explicit
501after the same policy check. Unsupported adapters fail explicitly after authorization.
The host receives an Opsmaru.Incus.Gateway.Request with the peer address and
TLS certificate from the server adapter, the authorization header and the
parsed selection. Body fields and other headers cannot change the principal or
the backend.
Relay behaviour
- One new backend connection per exchange (Unix or verified mutual HTTPS, from
the fixed
Connection); never pooled, retried or redirected. - The raw path and query are sent unchanged. Statuses, native errors, ETags,
Locationoperation URLs and workload bodies are relayed unchanged. The bounded server-identity exception and host-owned trust routes are below. - Never forwarded to the backend:
authorization,proxy-authorization,cookie,forwarded,x-forwarded-*,x-real-ip,expect, hop-by-hop fields and fields named inconnection.hostcomes from the backend connection. Everything else, includingx-incus-*, is forwarded. - Hop-by-hop response fields are removed. Response fields that could split a response are refused before commit. Cowboy writes repeated response fields as one comma-joined field.
- Request bodies stream in
max_chunk_bytesreads; sized bodies keep theircontent-length, unsized ones are sent chunked. Response bodies stream with backpressure: Cowboy acknowledges each chunk before the next backend read. - Response trailers are forwarded when the caller sent
TE: trailersand dropped otherwise. Request trailers are not forwarded.
Discovery, identity and host trust
identity/2 returns {:ok, identity} with these atom keys:
%{
origin: "https://gateway.example.com:8443",
addresses: ["gateway.example.com:8443"],
certificate: gateway_pem_certificate,
connection: exact_configured_backend_connection,
api_extensions: verified_backend_extensions_allowed_by_host_policy
}The host obtains this metadata through its own explicit verified inspection,
bound to that exact connection. Loading the library or handling anonymous
discovery never probes the backend. The origin must be an HTTPS origin;
addresses contain only approved public host:port authorities. Literal
private/loopback/link-local IPs, socket names, paths and credentials are refused.
The host is responsible for public DNS and for supplying the gateway listener's
actual certificate. The connection binding is never returned to a caller.
GET / is always synthetic (metadata: ["/1.0"]). Unauthenticated GET /1.0
returns native synchronous metadata with auth: "untrusted", public: false,
auth_methods: ["tls"], stable API version 1.0 and the approved extensions.
Both remain subject to readiness, admission, callback deadlines and body/header
budgets. Only an explicit unauthenticated decision permits discovery; malformed
credentials, forbidden authority and callback failures cannot become anonymous
access. No private environment is included in anonymous discovery.
Authenticated GET /1.0 uses the backend, buffering at most the smaller of
max_identity_bytes and max_response_bytes before committing a response. Its
metadata.environment.addresses and certificate are replaced with the public
gateway values. Every other native environment/envelope field is retained.
API extensions are intersected with verified host metadata and the gateway's
implemented-extension allowlist: certificate_update, instances, file_delete, console,
instance_nbd, custom_volume_sftp, storage_volume_nbd, container_push
and container_push_target. The two migration extensions require WebSockets. Protocol
extensions are filtered out when the corresponding optional adapter is absent.
The host must approve only protocols it actually mounts and a verified backend
supports; raw extensions require the same-listener Cowboy companion. Merely
installing an adapter does not authorize a protocol. Restricted-certificate
and token issuance gaps are listed under "Not provided by the library".
file_delete covers the native HTTP file DELETE route. Its advertisement still
requires both verified backend support and host approval. The pinned native
client's complete binary create/get/delete lifecycle is checked through the
verified TLS gateway, including encoded file queries and the original DELETE;
raw file relay alone does not bypass this client-side capability check.
This identity rewrite is required by the pinned v7.0.1 client's
GetConnectionInfo(): it starts with the connection URL, then appends HTTPS
URLs made from the advertised addresses. Guest/NAT/socket addresses must not
become migration fallback endpoints. The pinned client keeps the TLS connection
certificate as its pin. Native non-200 errors pass through unchanged. Invalid,
encoded or oversized successful identity bodies fail with a native-shaped
502, without committing backend bytes. Valid identity trailers are validated and dropped before rewriting; their
ETag/digest values describe the original representation. The changed representation gets a new
content length and drops the backend's ETag/digest validators, which describe
the original bytes. Workload responses retain their native validators.
All /1.0/certificates trust routes are handled locally, never proxied to guest
trust, including encoded aliases (which are rejected rather than forwarded).
The allowlist is GET/list, GET/fingerprint, POST/create, PUT/fingerprint and
DELETE/fingerprint; fingerprints are full lowercase SHA-256. The callback gets
the original request/query, original grant (nil for unauthenticated enrollment),
and %{fingerprint: ..., body: decoded_json, if_match: native_etag_or_nil}.
Bodies are bounded; chunked JSON is supported. The host returns
{:ok, native_metadata, headers} (only ETag/Location) or {:error, 400..599}.
The gateway produces the native synchronous envelope and native error shape.
Validation/encoding stays inside the killable callback budget. A timed-out
trust mutation is reported uncertain; it is never retried.
The host must verify owner/admin authority or a valid enrollment token against
the actual TLS peer. Request bodies cannot assert a verified principal.
Restricted certificates, nonempty project claims, server/metrics certificate
types and asynchronous token issuance are explicitly refused. Unrestricted
projects: null is accepted as empty, matching the pinned Go serializer.
Gateway TLS credentials, enrollment tokens and trust never become backend
credentials; backend HTTPS uses only the configured Connection's own identity.
test/support/gateway/certificate_host.ex is an isolated test-only host with
explicitly started, ephemeral Agent trust, owner/admin authority, expiry,
single-use token enrollment and revocation. It is not production trust storage
or an issuer. Production trust persistence, policy and token administration
remain owned by the embedding host.
Expiry and revocation
valid?(request, original_grant, host_config) must enforce current expiry and
revocation and return exactly true. The initial check and every active check
use Policy.callback; a timeout, raise, missing callback or any other result
denies access. Active checks use at most min(callback_timeout, recheck_interval / 4) milliseconds. recheck_interval defaults to 5000 ms
and may only be tightened (4–5000 ms).
Every local or relayed exchange, including anonymous discovery/enrollment,
monitors its original admission pid through Gateway.Lease. Admission loss
terminates blocked I/O, so a restarted admission cannot leave old holders
running outside its capacity. Local routes check admission before/after body
reads, before host callbacks and immediately before committing a response. A
trust callback that may have completed when admission is lost reports an
uncertain outcome; no callback is replayed. Anonymous leases monitor admission
without requesting or rechecking a grant.
An exchange-local Gateway.Lease reserves time for both a previously in-flight
check and the next check, plus interruption grace, inside that maximum. It runs
independently of backend reads and frontend writes. The HTTP relay checks its
validity before backend I/O and every forwarded chunk. Silent reads wake on
revocation; blocked uploads/downloads are forcibly interrupted within the bound.
A pre-commit failure is native-shaped; a committed response ends incomplete.
The guard monitors the exchange owner, and callback workers also stop if their
owner disappears. No library-owned global process or trust state is created.
Cleanup uses no new grant or connection and closes only gateway-owned local
resources. It never sends operation DELETE/cancel requests and never replays a
possibly accepted mutation. Gateway.Lease and Policy.recheck/3 are reusable
by later adapters. Only HTTP is verified in G2, with HTTP/1.1 socket fixtures
and a blocked-chunk adapter. G3 adds the independent WebSocket evidence below.
G4 verifies the same lease through raw takeover, including silent streams and
blocked writes.
Failures
Before a response is committed, the gateway answers with a native-shaped error
whose metadata.gateway.outcome states what the backend may have seen:
| outcome | meaning |
|---|---|
not_sent | No request bytes reached the backend. |
sent | A GET/HEAD was sent and its response failed. |
uncertain | Request bytes may have reached the backend; a mutation may have been accepted. Reconcile with /1.0/operations before retrying. |
metadata.gateway.operation carries a known operation URL when one was seen.
Timeouts are 504, backend failures 502, admission loss 503. Admission is
checked again before the backend connection opens and before the request is
sent, so an admission that stopped while the host callbacks ran is reported
not_sent with no backend contact.
max_lifetime and admission are checked before every backend read and before
each chunk is forwarded, so a busy stream with data already queued still stops
at its deadline.
After a response is committed, the gateway cannot send an error. On HTTP/1.x it
ends the caller's Cowboy connection without a final chunk (Ranch logs the
ended connection), so a truncated body is visibly incomplete; on HTTP/2 it
resets the stream. Frontend loss, deadlines and admission restarts close only
gateway-owned sockets. They never send DELETE to an operation or replay a
request.
Limits
| option | default |
|---|---|
callback_timeout | 1 s |
idle_timeout | the connection's stream_timeout (60 s) |
max_lifetime | 1 hour |
max_request_bytes, max_response_bytes | the connection's max_stream_bytes (1 GiB) |
max_chunk_bytes | the connection's max_chunk_bytes (64 KiB) |
max_header_bytes | 64 KiB |
max_identity_bytes | 1 MiB (identity/trust request and response bodies) |
recheck_interval | 5 s maximum including callback and interruption budgets |
Long waits such as /1.0/operations/{id}/wait?timeout=-1 are bounded by
idle_timeout; raise it for hosts that need longer waits.
Evidence status
Each evidence class is recorded separately, and none substitutes for another:
- Fixtures and pinned-client conformance: the portable suites described on
this page, plus the unchanged pinned v7.0.1 CLI matrix (18 successful cases
and six accepted snapshot-move limitations). See
docs/gateway-acceptance.md. - Package consumer:
mise run check:consumerbuilds the Hex package and compiles it in a fresh Phoenix consumer on Linux and macOS. Seedocs/gateway-acceptance.md. - Real Incus: the explicit isolated gate passed 28 of 35 cases on
2026-11-09, including every copy mode in both gateway orientations. Snapshot
move is the accepted pinned-native limitation, and NBD stays
unrununder an accepted limitation that is planned for a later revisit. Seedocs/gateway-real-incus-acceptance.md. - Installed host and Macus hardware: unrun here. These belong to the consumer that mounts the gateway.
Gateway evidence never changes priv/incus/coverage.json.
Not provided by the library
- Production trust persistence/issuer and host-specific authorization policy. The embedding host owns these.
- Restricted-certificate semantics and asynchronous certificate-token issuance; unsupported claims are refused rather than advertised.
Native WebSockets (G3)
The same gateway Plug mounts native GET /1.0/events and
GET /1.0/operations/{id}/websocket on the host's existing Plug.Cowboy
HTTP/1.1 listener. It negotiates a separate backend WebSocket over the fixed
Unix or verified mutual-HTTPS connection. The original encoded path and raw
query, including operation identity and secrets, survive unchanged; query
secrets are never logged or exposed in gateway status. Caller credentials and
proxy identity headers stay separate from backend TLS credentials.
Admission, readiness, authority and initial grant validity are checked before opening that backend. Invalid methods, routes, bodies, keys, versions or subprotocols fail before connection. Native version 13 is supported; HTTP/2 extended CONNECT and subprotocol negotiation fail explicitly. Compression is not negotiated, so message bytes are preserved independently of wire framing. The backend must complete a valid handshake before the frontend receives 101. A bounded native rejection retains its status, error body, ETag and operation identity, with safe trailers dropped. Nothing retries the initiating exec or console POST, a channel GET or a redirect.
Exec stdin/stdout/stderr, console data, event JSON and native resize/signal/exit text retain their message type and payload. Ping/pong are RFC controls local to each independently negotiated hop: Cowboy answers caller pings with the same payload; the backend relay answers backend pings. They are distinct from Incus JSON control messages. Backend close codes/reasons and queued final messages are preserved. Caller close is propagated locally where possible. EOF or a gateway failure closes the channel visibly; neither implies that an Incus operation was canceled or completed. Reconcile with the original native operation URL and authoritative operation metadata after interruption.
Each channel holds one admission slot until its local relay terminates. The
slot moves atomically from the Plug worker to the relay without reacquiring
capacity or a grant. The long-lived owner is the HTTP/1.1 Cowboy connection.
Handshake worker loss terminates the relay; after a normal worker exit the
frontend must attach within one second. Admission restart, grant revocation,
owner/worker loss and deadlines close local sockets and release capacity.
The same recheck_interval maximum applies while data is busy or silent and
while either direction is blocked. A separate bounded frontend close guard
survives relay termination so a nonreading caller cannot strand Cowboy output.
Its grace is the same min(50, recheck_interval / 4) milliseconds reserved by
the lease, retaining the host-tightened revocation bound.
Host grant callbacks run at negotiation and commitment boundaries. Once a channel is active, reads and caller messages check the independent lease; they do not invoke host policy on every frame or quiet read poll. Periodic lease checks still enforce the configured expiry/revocation bound.
Resource limits are explicit:
connection.max_response_bytesbounds each incoming/outgoing message and partial-frame assembly.max_request_bytesbounds relayed caller payload bytes;max_response_bytesbounds backend bytes including received framing.connection.queue_limitbounds decoded frames in one backend batch and the waiting backend writer queue. At half the writer queue count or byte budget, the adapter pauses inbound Cowboy socket reads; it resumes after both fall to a quarter. Reverse output and revocation/deadline handling remain active. Half the capacity is reserved for frames Cowboy has already received and continues parsing after a pause. Excessive coalesced bursts still terminate at the hard cap, including with deliberately tiny queue limits. The waiting writer payload bytes additionally cannot exceedconnection.max_response_bytes; at most one extra frame is being written.- Backend reads pause until Cowboy has processed the preceding batch. The frontend mailbox receives at most one outstanding backend batch; slow peers apply socket backpressure. Reader and writer run independently so blocked stdin cannot prevent stdout from draining.
idle_timeoutbounds inactivity and blocked writes;max_lifetimeis an absolute channel budget that traffic cannot refresh. Pre-upgrade rejected bodies are bounded by the smallest of the gateway response budget, the connection message budget and 1 MiB. Backend headers/trailers have a 64 KiB aggregate budget, enforced incrementally by Mint and the gateway collector.- Overflow/framing errors, revocation and deadlines terminate visibly rather than silently discarding events or leaving workers alive. Forced termination may close the socket without a close frame when a peer is blocked.
test/gateway_web_socket_test.exs records protocol, denial, partial-handshake,
large simultaneous duplex, active overflow, both blocked directions, busy and
silent deadlines, TLS separation, ownership and redaction evidence.
test/gateway_web_socket_reference_test.exs runs the unchanged pinned Incus
v7.0.1 Go exec, console, events and operation-WebSocket drivers through a
mutual-TLS gateway. Exec output/exit metadata and static/dynamic console results
are compared directly with their backend-fixture baselines. This is fixture
conformance; package consumer, real-Incus and installed Macus/hardware
evidence are recorded separately (see "Evidence status"). The typed-client coverage ledger is unchanged.
Native SFTP/NBD on the host listener (G4)
Configure a catch-all Cowboy dispatch entry in the existing host endpoint's listener options. The companion delegates ordinary HTTP and WebSocket requests to the host handler, including the original protocol-upgrade callback identity:
opts = %{
gateway: {MyHost.GatewayConfig, :current, []},
fallback: {Plug.Cowboy.Handler, {MyHostWeb.Endpoint, MyHostWeb.Endpoint.init([])}}
}
dispatch = :cowboy_router.compile([
{:_, [{:_, Opsmaru.Incus.Gateway.CowboyHandler, opts}]}
])
# Supply dispatch under the existing listener's protocol_options.env.
# The library starts no listener and changes no global host/ALPN setting.A static %Gateway{} is also accepted. Missing/invalid/dying configuration
resolvers fail closed with native 503. The host remains responsible for
listener TLS, verified client certificates and policy. Raw takeover requires
HTTP/1.1; an HTTPS host must permit http/1.1 in ALPN. Requests negotiated as
HTTP/2 cannot use this raw upgrade path. Ordinary host HTTP/2 traffic can still
use the fallback handler. Client-only consumers compile without any of the
Cowboy handler, raw relay or tunnel modules.
The companion accepts only body-free GETs with a matching Upgrade: sftp or
Upgrade: nbd, valid Connection tokens including upgrade, and these routes:
/1.0/instances/{name}/sftpand/1.0/instances/{name}/nbd/1.0/storage-pools/{pool}/volumes/{type}/{name}/sftpand the corresponding/nbdroute
Encoded segments and the complete original query (including project/member,
reuse and writable) are preserved. The shared policy authorizes the actual
peer, readiness and selected authority before any backend connection. Caller
credentials/proxy fields and x-incus-authenticated are stripped; only the fixed
backend connection can supply its authenticated assertion and TLS credentials.
Malformed methods, versions, bodies, route/protocol combinations or response
headers cannot switch the connection. Native non-101 rejection status, body,
operation identity and safe end-to-end headers reach the caller without retry.
One relay actor holds admission, lease and backend ownership throughout negotiation and takeover. The actor monitors the HTTP request worker until an acknowledged bounded handoff, then monitors the original Cowboy connection. Cowboy transfers frontend ownership to that same actor. Its prefetched client buffer and the backend's prefetched 101 bytes are delivered exactly once as opaque bytes, without WebSocket framing. A failed takeover closes the backend and releases admission; neither an HTTP worker exit nor an admission restart can leave an untracked raw connection.
Each direction has one writer and at most one outstanding read delivery. A read
is rearmed only after its bytes are written, so slow peers exert backpressure
without an unbounded mailbox or a duplex write deadlock. Chunks use
max_chunk_bytes; each direction uses the corresponding request/response byte
budget. Writes, silence, takeover and Unix half-close drain have finite bounds
from idle_timeout, the backend connection timeout and the absolute
max_lifetime. Lifetime starts before policy callbacks and includes connect,
negotiation and takeover; busy traffic cannot refresh it. Expiry/revocation and
admission loss terminate even blocked reads/writes within the configured lease
bound. Cleanup closes only local sockets/workers and never replays a request,
opens another backend, sends operation DELETE or implies workload cancellation.
TCP/Unix read EOF leaves its opposite write direction usable during finite
drain. OTP TLS close_notify ends that TLS connection, so further writes on
that peer are unavailable; this is the same explicit transport limitation as
the native client foundation.
test/gateway_raw_upgrade_test.exs records isolated fixture evidence: verified
HTTPS gateway/backend trust, the unchanged pinned Incus v7.0.1 instance/volume
SFTP/NBD helpers and pkg/sftp binary operations, denied requests, native errors,
coalesced bytes, duplex backpressure and lifecycle limits. The pinned Go
rawConn loses bytes prefetched by net/http: official-helper greetings are
therefore delivered separately under the existing approved oracle exception.
Independent opaque-byte tests coalesce both handshake directions and require
exactly-once preservation. These checks are gateway evidence; they do not
change typed-client coverage or claim real-Incus, installed Macus or hardware
acceptance.
Cross-server migration and operation-secret peers (G5)
The gateway relays the native migration requests and operation channels; the pinned client and backend own the copy/move workflow. Relay opens both sides' operation WebSockets through their respective gateways. Pull from a gateway source and push to a gateway target cause the other Incus server to connect to that gateway. Pull to a gateway target and push from a gateway source require the guest backend to reach the other endpoint itself. A native guest network failure passes through unchanged; the gateway never follows a private address, replays the migration request or substitutes another transport.
Hosts can implement this optional callback:
@impl true
def admit_operation_secret(request, config) do
# Resolve the ID from host-owned, verified pending migration state. Bind the
# stored backend/project/target and allowed peer to this exact exchange.
config.pending_migrations.authorize_operation_peer(request)
# Return {:ok, opaque_grant} or {:error, :forbidden}.
endThe callback runs only when ordinary authorize/2 explicitly returns
{:error, :unauthenticated}. Explicit forbidden decisions and failed ordinary
callbacks never fall through. The gateway additionally requires a WebSocket
GET on exactly /1.0/operations/{id}/websocket, one nonempty decoded secret
query parameter, no extra path segments or encoded slash in the ID, and no
all-projects selection. Missing/denied/malformed/late callbacks return 403
before any backend access. Duplicate or malformed project selections return
400; duplicate secrets, including encoded duplicate keys, cannot use this
admission path. The backend validates the original secret after the host's
allow decision, and the original path/query are relayed without reconstruction.
The pinned v7.0.1 server's migration dialer pins the remote certificate but
normally presents no client certificate. Its peer certificate may therefore
be nil. The host listener must permit certificate-less TLS handshakes while
ordinary host authorization continues to deny untrusted callers. Configure
certificate verification for presented client certificates according to the
host's trust policy; this callback does not weaken the TLS listener itself.
The native operation WebSocket helper also omits a project query even for a
nondefault-project operation. The host must resolve an omitted project from
its verified pending operation binding, rather than infer default-project
permission. An explicit conflicting project/target or another operation must
deny. Do not authorize an operation merely because it has a secret parameter,
a peer address or a certificate. The host's returned grant uses the same
valid?/3, revocation lease, admission and resource budgets as ordinary calls;
cleanup never acquires fresh authority or remotely cancels the migration.
Request/gateway inspection and generated errors redact credentials; hosts
must also keep secrets out of their logs and grants. Native backend response
bytes remain native and are not rewritten as gateway diagnostics.
Portable fixtures exercise unchanged pinned CopyInstance and
CopyInstanceSnapshot with stopped instances and stateless snapshots in all
three modes through both gateway endpoints, public connection-info addresses/certificate, binary control/fs channels, certificate-less
server peers and omitted nondefault-project selection. Separate interruption
fixtures isolate one frontend exchange from client-owned orchestration and
prove at most one backend POST with no gateway DELETE after frontend loss or
an uncertain reply. The pinned client itself owns its multi-public-address
attempts and explicit cancellations; those are distinct incoming requests,
not gateway retries. Unmodified CLI copy/move, package consumer and
real-Incus results are recorded separately (see "Evidence status"); installed
Macus/hardware evidence belongs to the consumer.
The G5 matrix verifies default copy behavior. Live migration, CRIU/stateful snapshots and optional flags such as instance-only/refresh are not gateway acceptance evidence in this slice. Their additional extension names remain outside the conservative gateway allowlist; this is an explicit gateway verification gap, not a claim that the backend or host lacks those features.
The pinned v7.0.1 CLI has a separate snapshot-move limitation: its shared
copy/move code recognizes snapshot sources only for the copy command. For
move source/snapshot, it instead requests
GET /1.0/instances/source%2Fsnapshot; the pinned server returns 400 Invalid instance name before any migration request or source deletion. The portable oracle preserves this rejection
rather than making the CLI pass against a permissive fake server. Successful
snapshot move is recorded as a pinned native limitation under the explicit
2026-11-07 user decision accepting that outcome as parity. The gateway does
not rewrite this route or add a compensating mutation, and successful snapshot
move is not claimed.