Skip to content

Reverse transport: run files-pull in the push direction (remote drives, local dials out)#313

Closed
adamziel wants to merge 15 commits into
trunkfrom
reverse-transport
Closed

Reverse transport: run files-pull in the push direction (remote drives, local dials out)#313
adamziel wants to merge 15 commits into
trunkfrom
reverse-transport

Conversation

@adamziel

@adamziel adamziel commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

What it does

Lets the ordinary pull pipeline move a site from a machine that cannot accept inbound connections (laptop, NAT, dev box) to a public remote. The remote runs the regular importer and owns all state — cursors, index, diff, file writes; the local site only ever makes outbound requests. An ordinary files-pull completes end to end over this reversed channel in the test suite, mirroring a source tree byte-for-byte.

In industry terms: the importer becomes a task-queue server and the source polls it over a reverse connection (the same model as GitHub Actions runners or GitOps agents), with each result piggybacked on the source's next request.

Rationale

Push previously meant building a second transfer subsystem (staging store, upload client, receiver endpoints) that reimplements what pull already does well. But the only thing actually wrong with pull for this direction is who dials whom. Inverting the transport keeps the entire tested pull pipeline — resume, delta diff, batching — and adds no new state machine:

  • Remote owns the state. There is no client-side belief about the target that can drift; the machine mutating the tree holds the cursor. A lost source just pauses the transfer.
  • No remote liveness problem. There is no long-lived importer process and no held socket. Each outbound request from the source is the trigger: it runs one bounded importer step and returns.
  • The local side is stateless. Export commands are read-only and cursor-driven, so an interrupted source loop is replaced by rerunning it.

Implementation

Three classes under packages/reprint-importer/src/lib/reverse-transport/ plus a two-line guard per choke point in ImportClient:

ReverseTransport — the importer-side transport. Holds the one source-delivered result as a stream and serves the importer's two fetch shapes from it: fetch_json() reads it whole (JSON endpoint responses are small); fetch_streaming() reads it in 64 KB chunks, inflating incrementally when gzipped (the way curl decodes Content-Encoding: gzip), and feeds the same MultipartStreamParser chunk handler the curl path uses — a multi-megabyte file_fetch result is never buffered whole. The request after the answered one throws TransportYield (extends Error so it slips past the importer's catch (Exception) wrapper), unwinding the importer back to the endpoint with the next command. No request/response matching: the protocol is strictly sequential and the importer resumes deterministically from its persisted cursor, so a single consumed flag is all the bookkeeping.

ReverseTransportEndpoint::handle_exchange — the remote's single endpoint. One exchange banks the delivered result, advances the importer by one request, and returns the source's next instruction:

request:  raw (possibly gzip) result bytes as the body — empty on the first
          exchange — with the reported HTTP status beside it (a header in
          production, an argument in-process)
response: { "status": "done" } | { "status": "command", "command": {...} }
        | { "status": "error", "message": string }

The body is deliberately not JSON-embedded: base64-in-JSON would force both sides to buffer it whole. Commands are small; results are not. The endpoint re-enters the real importer (fresh ImportClient per pass, like an exit-code-2 resume; the same ReverseTransport across passes so the result is consumed exactly once) until it yields or completes, and never throws — importer failures come back as status: "error" so the source can tell a remote failure from transport garbage.

ReverseTransportSource — the outbound-only loop on the source site: deliver the previous result, receive the next command, run it against the source's own export.php (a hostile remote can only ask for what the source already exposes), repeat. The export output ships exactly as the engine produced it (typically gzip), spooled to a temp file — never a PHP string. Any exchange failure throws; the source never re-sends a result. Recovery is the remote re-asking from its persisted cursor: a rerun starts with no result and the remote re-issues exactly the request it is missing. Re-sending could hand a stale result to a newer request (results carry no ids), so the exchange callable must not retry either.

ImportClient — gains set_reverse_transport() and a delegation guard at the two curl choke points:

if ($this->reverse_transport !== null) {
    return $this->reverse_transport->fetch_json($url);
}

The curl path stays inline and untouched: it is welded to client state (HMAC signing, tuner hooks, progress heartbeats, curl diagnostics), so one interface over both paths would relocate ~450 battle-tested lines for zero behavior change. The adaptive tuner is skipped entirely in reverse mode (as with --no-adaptive): it paces a dialing client with duty-cycle sleeps, and on the remote those sleeps would run inside the exchange handler. import.php PHPCS unchanged (504/74). No cursor-persistence changes were needed: the importer already checkpoints at exactly the per-request boundary exit-code-2 resume uses, so a yield lands on an existing checkpoint.

Failure contract (mirrors the curl path, message-for-message): a 200 body with no multipart boundary or no completion chunk throws the curl path's exact Invalid response: … errors (which also keeps sql_chunk's crash-recovery classifier working); fread/inflate_add failures throw instead of truncating; non-200 results carry the server's error body up via ReverseTransportHttpError, and the guards run it through the same diagnose_http_error block the curl tail uses (extracted into a shared throw_diagnosed_stream_error()), so fetch_json returns curl's exact non-200 shape — an HTTP 500 error body can no longer pass the preflight payload !== null gate. Two invariant guards refuse loudly what the checkpointing can't yet support: the endpoint whitelists commands that persist a checkpoint before every request (files-pull), and follow_symlinks is rejected over the reverse transport (symlink discovery re-scans without a sub-stage checkpoint). A re-entry pass cap turns any residual no-progress loop into an error.

Not in this PR: the HTTP glue (an HMAC-authenticated route + CLI carrying the exchange over curl), the source's real loopback into its own export.php (tests use a subprocess because the exporter tears down output buffering to stream), db-pull (same seam via sql_chunk/db_index; needs a live MySQL to verify), and the sub-stage checkpoints that would let follow_symlinks and the composite pull/preflight ride the reverse transport.

Testing instructions

cd tests && ../vendor/bin/phpunit --testsuite "Reverse Transport Tests"

ReverseTransportTest runs the real importer against the real exporter — no mocks of either:

  1. Happy pathfiles-pull mirrors a 4-file source tree (incl. binary content) byte-for-byte in three exchanges (file_indexfile_fetch → done).
  2. Streaming — a 24 MB file (gzip-window-defeating content, so the wire really carries ~24 MB) transfers with peak memory growth under 8 MB; identity checked by hash.
  3. Crash-resume — the source loop dies before delivering the fetch result; a fresh one starts with no result, the remote re-asks from persisted state, and finishes byte-for-byte.
  4. Transport failure — a 502-style garbage response throws malformed exchange response instead of finishing clean.
  5. Remote failure — an importer exception surfaces on the source with its original message via status: "error".
  6. Garbage-200 body — a non-multipart 200 result aborts with missing multipart boundary instead of re-asking forever.
  7. Truncation — a result cut before the completion chunk fails with missing completion chunk, not a clean partial.
  8. Corrupt gzip — a flipped byte fails loudly instead of silently truncating the transfer.
  9. Non-200 as data — a 500 JSON error body converts to curl's exact non-200 fetch_json shape (json: null, error_code set).
  10. Guards — unsupported commands and follow_symlinks are rejected up front; a no-progress importer loop is capped, not spun.

Design only, no code. Describes running the ordinary pull commands in the
push direction over an outbound-only local connection: the remote runs the
importer (owns cursors/index/apply/final writes) and the local runs a
stateless source worker that delivers the previous export command's result
and takes the next on each outbound request. One transport seam makes every
regular command ride it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Pull pipeline performance — large-directory

Site: large-directory · 2,000+ plus targeted file-transfer scenarios files · 10,000 posts · 25,000 postmeta · PHP 8.5.8

Stage PR trunk Δ Status Details
playground-sqlite-db-pull 9.30 s 9.12 s ⚪ +179 ms (+2.0%) condition=db-pull in PHP.wasm
runtime=php.wasm 8.3
wp_mysql_parser=enabled
mode=lexer
native_lexer=verified
native_token_stream=WP_MySQL_Native_Token_Stream
native_token_count=18
native_parser=selected
trunk: condition=db-pull in PHP.wasm
runtime=php.wasm 8.3
wp_mysql_parser=enabled
mode=lexer
native_lexer=verified
native_token_stream=WP_MySQL_Native_Token_Stream
native_token_count=18
native_parser=selected
playground-sqlite-db-apply 3.60 s 3.52 s ⚪ +81 ms (+2.3%) condition=db-apply to SQLite in PHP.wasm
runtime=php.wasm 8.3
wp_mysql_parser=enabled
mode=parser
native_lexer=verified
native_token_stream=WP_MySQL_Native_Token_Stream
native_token_count=18
native_parser=verified
native_ast=WP_MySQL_Native_Parser_Node
sqlite_driver_parser=verified
trunk: condition=db-apply to SQLite in PHP.wasm
runtime=php.wasm 8.3
wp_mysql_parser=enabled
mode=parser
native_lexer=verified
native_token_stream=WP_MySQL_Native_Token_Stream
native_token_count=18
native_parser=verified
native_ast=WP_MySQL_Native_Parser_Node
sqlite_driver_parser=verified
Total 12.90 s 12.64 s ⚪ +260 ms (+2.1%)

Numbers carry runner noise; treat single-run deltas as directional, not authoritative.

📈 Trunk performance history — commit-by-commit timeline.

Implements the single-endpoint reverse pull: the remote runs the importer
and owns all state; the local source is outbound-only. One `relay_exchange`
call both delivers the previous export command's result and takes the next.
The endpoint *is* the importer, driven one step per call — a `TransportYield`
unwinds the importer cleanly after it issues its next request, reusing the
existing resume-from-cursor discipline. No second remote process, no held
socket; the local worker's outbound request is the trigger.

Four small classes under packages/reprint-importer/src/lib/relay/:
  - RelayTransport: returns the one delivered result or throws TransportYield
  - TransportYield: carries the pending command back to the exchange handler
  - RelayExchange: runs the importer inline; catches the yield, returns command
  - RelaySourceWorker: outbound-only loop; executes each command locally

ReverseTransportDemoTest moves real file bytes source -> destination over the
reversed channel (byte-identity assertion) and covers crash-resume. Its
stand-in file source and mirror driver play the parts export.php and files-pull
take in production; wiring the real ones through this seam is the follow-up.

Updates markdown/REVERSE-TRANSPORT.md to make the single-endpoint model primary
and records remote-liveness as solved (the outbound request is the trigger).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@adamziel adamziel changed the title Sketch: reverse transport (push as a remote-driven pull) Reverse transport: push as a remote-driven pull (mechanism + demo) Jul 6, 2026
adamziel and others added 7 commits July 6, 2026 20:26
…ull)

Adds a transport seam to ImportClient so an ordinary files-pull runs entirely
over the reverse channel — real importer, real export.php — with the source
outbound-only. Direct mode is untouched: fetch_json/fetch_streaming take a relay
branch only when set_relay_transport() is called, and import.php's PHPCS count is
unchanged (504/74).

How it hangs together:
  - A request's URL is its relay command identity. build_url drops the
    non-deterministic cache-buster in relay mode so a re-issued request after a
    yield fingerprints identically to its delivered result.
  - relay_fetch_streaming feeds the worker-carried body to the SAME
    MultipartStreamParser + chunk handler the curl path uses; the boundary is
    read back from the body (no capturable Content-Type header in-process).
  - TransportYield now extends Error, not Exception, so it slips past the
    importer's `catch (Exception)` command wrapper without persisting an error
    status. No explicit "persist cursor at the yield boundary" code is needed:
    the importer already checkpoints at exactly the exit-2 request boundary.
  - RelayExportSource turns a command back into export.php's request array and
    gunzips the streamed response. RelayImportDriver re-enters the real importer
    per exchange (fresh client, like an exit-2 resume) until it yields or
    completes.

tests/Relay/ReverseTransportPullTest.php runs the real files-pull against the
real export.php over the reverse channel and mirrors a source tree to the
fs-root byte-for-byte in two exchanges (file_index then file_fetch). The source
export runs in a subprocess (the exporter tears down output buffering to stream,
so it can only be captured from a real output stream); production would loopback
into the source's export.php instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RelayExchange works in arrays; a real relay_exchange HTTP hop is JSON. Every
value is already JSON-safe except the delivered result's body — raw gunzipped
multipart bytes — so RelayExchangeEndpoint carries it base64-encoded as
result.body_b64 and decodes it back for RelayExchange. handle_json() is the
remote body handler; static encode_request()/decode_response() are the source
worker's side, so the contract lives in one place.

ReverseTransportWireTest runs the same real files-pull as the pull test, but the
worker↔endpoint boundary now crosses JSON strings via RelayExchangeEndpoint —
proving the wire format end to end, including the binary-body base64 the
in-process path hid. The source tree still lands on the fs-root byte-for-byte.

Remaining (documented in REVERSE-TRANSPORT.md): the HMAC route + relay-source
CLI that carry these JSON strings over curl, the loopback export client, and
db-pull (same seam, needs a live MySQL to verify).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ngerprinting

The mechanism was over-built. Collapsing it, with the same real files-pull still
green end to end over a JSON wire:

- Drop request/response fingerprinting (command_id/md5) and the build_url
  cache-buster special-case. The exchange is strictly sequential and the importer
  resumes deterministically, so "the delivered result answers the first request
  on re-entry" holds without matching — a single consumed flag is enough.
- Merge the three remote classes (RelayImportDriver + RelayExchange +
  RelayExchangeEndpoint) into RelayExchange::handle_json: decode JSON, re-enter
  the real importer until it yields or completes, encode JSON.
- Merge the two source classes (RelaySourceWorker + RelayExportSource) into
  RelaySource: the outbound loop plus command->request translation and gunzip.
- Keep RelayTransport: it holds the one delivered result and a consumed flag that
  must survive the fresh-client re-entry loop (moving it onto the recreated
  client would let the second pass re-consume the result at an advanced cursor).
  This is the one abstraction that earns its keep; documented as such.
- Collapse the demo + array-level pull + wire tests into one real files-pull over
  the JSON boundary (ReverseTransportTest).

Relay lib 474 -> 262 lines (8 -> 5 files incl. load), relay tests 644 -> 174
lines (3 -> 1 test). import.php back to 504/74 PHPCS, direct mode untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The mechanism is small enough now that the class docblocks carry the design:
the sequential-exchange rule and the why-RelayTransport-exists rationale live
on RelayTransport, the endpoint contract on RelayExchange, and the security
posture on RelaySource. A separate design document would just drift.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Answers "put the network handling in the transport and call to that": the
four relay_* marshalling methods (~125 lines) leave ImportClient and become
RelayTransport's own fetch_json()/fetch_streaming(), mirroring the client's
two fetch shapes. The client's relay branches shrink to delegation:

    if ($this->relay_transport !== null) {
        return $this->relay_transport->fetch_json($url);
    }

The curl path stays inline rather than moving behind the same interface: it
is welded to client state (HMAC signing, tuner error hooks, spinner/heartbeat
progress, curl-state diagnostics, audit log), so extracting it would relocate
~450 battle-tested lines and expose a pile of private internals to make an
interface with exactly two implementations. The two-line guard is the honest
cost; extract the curl side if a third transport ever appears.

net: import.php -130 lines of relay logic; RelayTransport is self-contained
(deliver-or-yield, command building, boundary recovery, multipart parse).
Same e2e test green; import.php PHPCS unchanged (504/74).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The class is the importer-side piece of the reverse transport, and the name
should say which mode the guard switches on. Property, setter, and file name
follow (reverse_transport / set_reverse_transport / class-reverse-transport).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RelaySource treated any non-JSON exchange response the same as "done" — over
real HTTP, a 502 page or timeout garbage would end the worker mid-transfer
looking successful. Now the wire has three statuses (done | command | error)
and anything else throws:

- RelayExchange::handle_json never throws; a non-yield importer failure comes
  back as { status: "error", message } so the worker can tell a remote failure
  from transport garbage.
- RelaySource throws on the error status (carrying the remote's message) and
  on malformed responses. It never re-sends a result: the remote's persisted
  cursor is the recovery mechanism — a rerun starts with no result and the
  remote re-asks. Re-sending could hand a stale result to a newer request
  (results carry no ids to match), so the docblock also warns the future HTTP
  glue off blind retries.

Tests: a crash-resume case (worker dies before delivering the fetch result; a
fresh worker resumes from the remote's state and finishes byte-for-byte) plus
the two failure modes, all against the real importer/exporter where relevant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@adamziel adamziel changed the title Reverse transport: push as a remote-driven pull (mechanism + demo) Reverse transport: run files-pull in the push direction (remote drives, local dials out) Jul 7, 2026
adamziel and others added 6 commits July 7, 2026 02:05
RelayTransport had become ReverseTransport while its siblings kept the relay
name — two words for one mechanism. Now the family is uniform:

  RelayExchange -> ReverseTransportEndpoint  (the remote side)
  RelaySource   -> ReverseTransportWorker    (the outbound-only local side)
  lib/relay/    -> lib/reverse-transport/
  tests/Relay/  -> tests/ReverseTransport/   (suite "Reverse Transport Tests")

Docblocks, error messages, temp-file prefixes, and the test namespace follow.
No behavior change; suite green, import.php PHPCS unchanged (504/74).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
handle_json said how the payload is encoded, not what the method does. The
renames make each name carry its object:

  ReverseTransportEndpoint::handle_json    -> handle_exchange
  ReverseTransport::deliver                -> serve_delivered_result
  ReverseTransport::build_command          -> build_export_command
  ReverseTransport::extract_boundary       -> extract_multipart_boundary
  ReverseTransportWorker::execute          -> execute_export_command
  ReverseTransportWorker::build_request    -> build_export_request
  ReverseTransportWorker::$exchange        -> $send_exchange_request
  ReverseTransportWorker::$run_export      -> $run_export_request

fetch_json/fetch_streaming keep their names on purpose — they are the transport
counterparts of ImportClient::fetch_json/fetch_streaming and the guard there
delegates 1:1; the docblocks now state that parity. Worker::run() gains
@param/@throws docs; the test's $exchange variable (which held the endpoint)
becomes $endpoint, and its put() helper becomes writeFile().

No behavior change; suite green, PHPCS sniff set unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… whole

The wire embedded the result body as base64 inside the exchange JSON, which
forced whole-body buffering several times over on both sides (raw + base64 +
JSON string), and fetch_streaming() fed the parser one giant string. Fine for
commands; wrong for file_fetch results, which can be many megabytes — the
direct curl path streams all of this.

The result now travels as the raw request body and stays a stream end to end:

- ReverseTransportWorker ships the export output exactly as the engine
  produced it (typically gzip), spooled to a temp file by the export runner —
  never a PHP string. The gunzip moved off the worker entirely.
- ReverseTransportEndpoint takes (resource|null $result_stream, int
  $result_http_code) — the HTTP body and a header, once the glue exists —
  instead of a JSON string.
- ReverseTransport::fetch_streaming() reads the stream in 64 KB chunks,
  inflating incrementally when gzipped (the way curl decodes
  Content-Encoding: gzip), and feeds the multipart parser per chunk. The only
  buffered prefix is up to the first newline, to recover the boundary.
  fetch_json() still reads whole — JSON endpoint responses are small.

The command (response) direction stays small JSON. The one JSON-embedded
payload that can reach megabytes is the inlined file_fetch batch list, bounded
by the batch size and equal to what the direct path holds when signing.

New regression test: transferring a 24 MB file (gzip-window-defeating content,
so the wire really carries ~24 MB) must grow peak memory by less than 8 MB;
byte-identity checked by hash so the assertion itself stays out of memory.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…orker"

"Worker" reads as a managed process from a pool; this is just the source side
of the exchange — the site that has the data, dialing out and answering export
commands. "Source" is also the codebase's existing word for that role, so the
pair is now ReverseTransportEndpoint (remote) / ReverseTransportSource (local).
Docblocks, error prefixes, and the tests follow; the test's $source string
property becomes $sourceRoot to free the name for the object.

No behavior change; suite green, PHPCS sniff set unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review findings from the reverse-transport pass, cleanup tier:

- Don't create the AdaptiveTuner when the reverse transport is set. The tuner
  adapts request size and paces the dialing client with duty-cycle sleeps
  between its own requests; over the reverse transport the remote is not the
  dialer — its requests are answered inside the source's exchange calls, where
  the pacing usleep (finalize_tuned_request) would only hold the exchange
  open. Untuned means get_tuned_params sends no knobs and the exporter's
  server-side defaults rule, exactly like --no-adaptive.
- Delete the guard's response_stats zeroing: StreamingContext initializes the
  array, every completion handler overwrites it wholesale, and the tuner never
  read ttfb/total_time — dead code with a false comment (and moot now).
- Build the wire command lazily: serve_delivered_result takes a builder
  callable invoked only on the yield path, so the consume pass no longer
  re-reads and base64-encodes the whole file_fetch batch list just to throw
  it away.
- Fix the test teardown leak: rmrf used glob('*'), which skips dotfiles — the
  state dir is full of .import-* files, so rmdir failed silently and every run
  leaked temp dirs. Replaced with the suite's scandir-based recursiveDelete
  idiom; verified a full suite run now leaves zero reverse-transport-* dirs.

Suite green (5 tests), import.php PHPCS unchanged (504/74).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The review's correctness findings, fixed by parity with the curl path — same
messages, same shapes — plus loud refusals for the two paths whose
checkpointing can't yet uphold the reverse transport's resume invariant.

Transport validation (was: silent success / silent truncation):
- A 200 body that never yields a multipart boundary now throws the curl
  path's "Invalid response: missing multipart boundary" (with a body
  snippet); the guard enforces "missing completion chunk from server" after
  parsing, exactly as the curl tail does. A deterministically-failing source
  now aborts on the next exchange instead of re-running its export up to
  max_exchanges times. Message parity also revives the sql_chunk crash
  recovery, which classifies retryable failures on those exact strings.
- fread()/inflate_add() false returns throw instead of being cast to "";
  corrupt gzip fails like curl's CURLE_BAD_CONTENT_ENCODING rather than
  truncating. The gzip sniff reads exactly two bytes so a short first read
  on a socket-backed stream cannot misclassify the encoding.

Error diagnosis (was: generic message, server body discarded, wrong types):
- Non-200 results throw ReverseTransportHttpError carrying the (capped,
  gunzipped) error body. The ImportClient guards convert it with the same
  diagnosis the curl path uses: fetch_json returns curl's exact non-200
  shape (json=null, error_code — a 500 error body can no longer pass the
  preflight "payload !== null" gate), and fetch_streaming calls the
  diagnose-and-throw block now extracted from the curl tail into
  throw_diagnosed_stream_error() (shared, so the "HTTP error 4xx" shapes
  callers classify on are identical, and the server stack trace survives).

Checkpoint-invariant refusals (was: silent livelock):
- ReverseTransportEndpoint accepts only commands that persist a checkpoint
  before every export request (files-pull); preflight and the composite pull
  re-run multi-request sequences from scratch on re-entry and would pair
  results with the wrong request.
- run() refuses follow_symlinks over the reverse transport: symlink
  discovery issues follow-up file_index requests with no sub-stage
  checkpoint. Refused loudly rather than silently skipping symlinks.
- handle_exchange caps re-entry passes so a no-progress loop fails the
  exchange instead of spinning inside one HTTP request.

Seven new tests cover each failure mode: garbage-200, truncation, corrupt
gzip, non-200-as-preflight-data, rejected commands, symlink refusal, and the
pass cap. import.php PHPCS unchanged (504/74).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@adamziel

adamziel commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

Closing after the design pivot in #315: push stays local-driven — the remote is a passive authenticated API, no polling loop, no remote-owned cursors. This reverse-transport experiment answered the question and lost the argument; the outbound-only constraint it solved is handled by ordinary outbound requests in the new flow.

I almost went through with this one, but I couldn't make myself like the remote site requesting paths from a local site. It's a privacy and security boundary I don't think Reprint should cross. We'll do a regular push, not inverted pull. The local site will choose what to communicate and the remote site will validate it and accept it.

@adamziel adamziel closed this Jul 7, 2026
@adamziel adamziel mentioned this pull request Jul 7, 2026
12 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant