Saphan StudioDocs
Integrations

Client-contract gateway

What the gateway serves, how you sign in, what answers today, and how it refuses.

saphan-gateway is a separate process. It serves the client contract beneath /v1, operational metrics at /metrics, and a human-viewable console at /console, where every web screen the inventory declares has its own address. It keeps no database and no cache of decisions; its only long-lived objects are HTTP lifecycle objects, the immutable route registry, the embedded UI assets the screen renders from, and — since 2026-08-31 — an in-memory table of console sessions.

Before you paste any command in this document: every curl against /v1/* needs a session cookie, and it must be sent as an explicit header — the cookie is Path=/console-scoped, so a cookie jar will not send it to /v1. Read § Signing in below first: one login, one header extraction ($SAPHAN_AUTH), and every example here works. Four addresses stay open and need no cookie — GET /, GET·POST /console/login, GET /metrics and GET /v1/capabilities — of which only the last two return anything (§5).

Surface census — product contract 3.1.0 · 41 registered · 9 implemented · 32 refusing. (The product version and the OpenAPI syntax version both read 3.1.0 at this release. They are two independent numbers that happen to agree, and only the first one moves with this surface.)

  • GET /v1/gates — the fleet's owed-gate queue: only the streams that owe a human an act, in merge-proximity order.

  • GET /v1/streams — the resolved fleet register: every stream that exists, gated or not, terminal or not. A fleet with nothing owed answers the first with an empty queue and the second with its whole register; they are not one question with a flag.

  • GET /v1/ledger — the cost ledger, windowed, grouped and paged. The first operation whose source is not the fleet register: it reads cost_ledger, not the reconciled channel files.

  • GET /v1/overview — the console's startup screen: one number for the owner, three counts for the fleet, one spend series. The first operation that composes — it is the only one of the five whose answer comes from three tables at once (the fleet register, capture_index and cost_ledger), and it takes no parameters at all: one object, fixed shape, no window, no grouping, no page.

  • GET /v1/sparks — the sparks list: one page of capture_index, newest first, windowed and paged. It closes the loop GET /v1/overview opened — the console counted sparks that nobody could list. It is also the first operation whose contract asks for more than the record holds, and the first that says so in the bytes: four of its declared filters (state= worked, q, tag, since) cannot be applied, and every reply carries a per-parameter filters object plus filters_unserved naming them. Read § GET /v1/sparks below before writing a client against it — one of its required fields is always empty and the reason is a fact about the record.

  • GET /v1/policies — the policy shelves: how many rules of each named family stand in the signed machine registry, paged. It is the first operation whose source is not a table this process opened at all — every count is taken over root-pin-verified identity_record rows through registry.LoadMachines, and the record handle is only the precondition that a resolved record exists. It is also the second operation that says what it could not answer, and the first to say it per shelf: the Egress shelf is published in shelvesUnserved with its reason instead of as an empty shelf, because count: 0 reads as there are no egress policies where the truth is nothing here can tell you. Read § GET /v1/policies below before showing a shelf count — shelvesComplete belongs next to it on the screen.

  • GET /v1/fleet/seatsthe fleet's seats and what the record knows about each one's usage: who your seats are, how much of each one's session and week is spent, and when each window renews. It is the first operation whose answer JOINS two sources of different ages — the signed machine registry (which seats exist right now, machines in service only) against seat_usage_reading (what was last measured about each). It never probes: it spawns no vendor, makes no network request and writes no row, so it can be polled without spending anybody's quota, and the price of that is that every number carries its own age_seconds. It is the third operation that publishes what it could not answer, and the first to do it per field: four facts the terminal prints are absent from the durable table and are named in fields_unserved with their repairs. Read § GET /v1/fleet/seats below before rendering a seat — used_pct is always SPENT while one of your two backends reports REMAINING, and knowledge distinguishes three answers a screen must not collapse.

  • GET /v1/gates/{gateId}one owed gate, and the first single-item read on this surface. The {gateId} is the key field GET /v1/gates already publishes on every row (<stream>·<gate>, so it is percent-encoded in a URL: alpha%C2%B7stop2), and no other spelling is accepted — an id taken from the list always resolves, an id composed by hand may not. Byte for byte, including whitespace: the key is not trimmed, so %20alpha%C2%B7stop2 is a key no row publishes and answers 404. One row, one name. ⚠ It resolves against the owed-gate queue, which is the only projection in this engine that carries a pending gate, so 404 means no gate with this key is owed right now and not no such gate ever existed: a gate the owner has already decided has left the queue and answers exactly as an unknown key does. Every 200 carries that sentence in its own note field.

  • GET /v1/sparks/{sparkId}one spark, and the operation the console's spark-reader screen had no data for. The {sparkId} is the decimal id that GET /sparks publishes on every row, in that exact spelling: 01, +1 and 1 are not the string the list prints for row 1, so they answer 404 — and the refusal prints the spelling that works, so a client one character off has the repair in its hand. It is not the ULID the contract's write side mints: POST /sparks has the client mint a ULID, POST /v1/sparks is not served by this gateway, and capture_index has no column that would hold one — so a ULID handed to this operation is reported absent with that reason spelled out, rather than looked up. It inherits every limit of the list beside it, because it is folded by the same function: text is empty on every row and state reads captured on every row. Both notes travel on every reply (text_note, state_note, id_note) rather than being left in this document, because a client of this operation may never have called the list.

The other 32 refuse with 503 and the Problem type urn:saphan:gateway:not-implemented. That is deliberate and observable, not a hidden placeholder:

  • act cannot yet preserve gateRev, authority, hardware signature time, device identity, or biometric evidence through the current engine-door request;
  • capture cannot yet preserve the signed SparkEnvelope, and that envelope contains no authenticated actor for the current capture door;
  • the admitted enroll verb appends an identity_record, while POST /v1/enrollment must mint a short-lived QR payload.

Calling any of those similarly named functions would report an operation as implemented while dropping signed contract data. /metrics reports the implemented/not-implemented counts directly.

Signing in (the human act) It does not any more. /console/* and /v1/* require a console session, and this

chapter is the runbook for getting one — as a human at a browser, and as a shell holding curl.

The credential is the workspace's master-admin credential, the same one docs/Console (admin) describes and the same one the engine's own /console uses. There is no second credential, no second session mechanism and no gateway-specific account.

Set the credential, once per workspace

In a shell on the host, in the workspace this gateway will serve:

saphan console admin set --user <name>

The verb asks for the password on your terminal, twice, with echo off. It is never typed on a command line. The minimum length is 12 characters. saphan console admin status names the file and its mode without printing anything from inside it.

Restart the gateway if it was already running

The gateway reads the credential once, when its router is built. A credential set under a running process is not seen by that process — it goes on refusing until it is restarted. If you set the credential first and start second, there is nothing to do here.

Log in, in a browser

Open the listen address. Any /console address you ask for redirects to the form and remembers where you were going:

http://127.0.0.1:7656/console

You land on /console/login. Type the user and password from step 1. On success you are put back on the address you asked for. The page states, in one sentence, which cookie mode the connection is in — on plain HTTP to loopback the cookie is deliberately not Secure, because a Secure cookie is discarded by the browser on such a connection and login could not work at all. That exception is loopback-only and the page says so; off this machine, serve the gateway over HTTPS.

To sign out, return to /console/login — with a live session it shows who you are and a Sign out button. Sign-out is a POST carrying a per-session token, so no link on any other site can sign you out.

Log in, from a shell

curl needs a cookie jar to log in. One login, then every read in this document reuses the session it captured:

SAPHAN_JAR=$(mktemp)
curl -s -o /dev/null -c "$SAPHAN_JAR" \
  --data-urlencode "user=<name>" --data-urlencode "password=<password>" \
  http://127.0.0.1:7656/console/login

⚠ The line above puts the password on a command line, where the host's process table and your shell history can both see it. For anything but a throwaway loopback session, read it from a variable your shell did not record, or use the browser.

The jar alone will not authenticate /v1, and that is deliberate. The session cookie is issued with Path=/console, so a standards-conforming cookie jar — curl's included — sends it on /console/* requests and withholds it from /v1/*. That narrow scope is the cookie's browser defence: on a hostname where a reverse proxy co-hosts anything else, a neighbour under another path prefix never receives this session. Machine clients are not bound by that scoping, because they set the header themselves. So read the session out of the jar once and carry it as an explicit header:

SAPHAN_AUTH="Cookie: saphan_console_session=$(awk '$6=="saphan_console_session"{print $7}' "$SAPHAN_JAR")"

Then every guarded /v1 read in this document carries -H "$SAPHAN_AUTH":

curl -H "$SAPHAN_AUTH" --fail --show-error http://127.0.0.1:7656/v1/gates

If $SAPHAN_AUTH comes back with an empty value, the login above did not succeed — re-run it without -o /dev/null and read what it answered. A /v1 read with an empty cookie value gets the same 401 as no cookie at all, so an empty extraction looks exactly like a rejected session.

⚠ Browser reads of /console/* need none of this: the browser holds the cookie and its path scoping already matches. This step exists only because a shell client reads /v1.

What is open without a session, and it is exactly four addresses

Four addresses answer with no cookie. Two of them are reads — a scrape and a contract lookup — and two of them are the way in. An operator auditing the unauthenticated surface must count all four; an operator asking what data leaves this process unauthenticated looks only at the first two.

The two that RETURN something without a session:

addresswhy it is open
GET /metricsoperational scrape surface. Route and admission counts only, no record fact, and a scraper holds no cookie.
GET /v1/capabilitiesthe signed contract declares security: [] on this operation and on no other — a client must learn where to log in before it can log in.

The two that are the door itself:

addresswhy it is open
GET /a redirect to /console, and /console then refuses. It carries no record fact; it is a signpost.
GET·POST /console/loginthe login form. A guard that guarded its own login would be a door with no handle.

/metrics is open deliberately, so that a Prometheus scraper holding no cookie can read it. Whether it should stay open is an owner's question and it is parked, not settled here.

When it refuses

what you seewhat it meanswhat to do
/console/... bounces to /console/loginno session, or a cookie that is absent, expired, forged or signed outlog in (step 3)
/v1/... answers 401 with "type":"urn:saphan:gateway:no-session"the same five states, in the shape a machine client can decodelog in (step 4) and repeat with -H "$SAPHAN_AUTH" — a jar's -b will not carry a /console-scoped cookie to /v1
every address answers 401/503 naming console admin setno credential is set for this workspace, or the file is unreadablesaphan console admin status, then step 1, then restart the process
login answers 429 with Retry-Afterfive failed attempts from your address inside 15 minuteswait the stated time; the correct password is locked out too, on purpose
login succeeds in the browser but the next page bounces backthe cookie was discarded — you are on plain HTTP and not on loopbackserve the gateway over HTTPS, or reach it from the host itself
sign-out answers 403the form's per-session token was missing or belonged to another sessionreload /console/login and use its own button

A restart signs everybody out. The session table is in memory, and the key that signs the cookies is minted per process. That is not an oversight — a durable session table needs a store migration — but it is a fact to plan around: a gateway restarted mid-review costs every open browser one login.

What signing in did NOT change. The listener is still loopback-only; Resolve refuses a non-loopback listen address and this release does not amend that. Authentication is not a licence to bind a public interface — that is mTLS's wagon and an owner-staged decision. And the row scope is still unrestricted: this surface authenticates exactly one principal, the workspace's master admin, so its rows were always the whole fleet. The day a second principal can log in here, that stops being true.

Start, check, and stop

These are the human acts required to operate the process. The examples use a loopback listener because the process speaks plaintext; terminate TLS in a reverse proxy on the same machine and forward /v1 to it.

The verbs are the fleet's, and serve is gone. saphan-gateway is driven exactly like saphan serverd: start (foreground by default, --detach to background it), stop, status. serve existed for one release and now refuses with a message naming its replacement — if a script or a unit file still says serve, that refusal is where you will find out.

  1. Add the optional listen declaration to /etc/saphan/config. Omit it to use 127.0.0.1:7656:

    [gateway]
    listen = "127.0.0.1:7656"
  2. Start the process in the foreground, naming the workspace absolutely:

    /usr/local/bin/saphan-gateway start --config /etc/saphan/config --workspace /srv/saphan/engine

    Keep it under the service supervisor used on that host. SIGTERM and SIGINT both begin a five-second graceful shutdown.

    To background it without a supervisor, add --detach. It re-execs itself in a new session with its output redirected to the daemon log, and returns only once the gateway is confirmed to be answering — a failed start reports a non-zero exit with the log's tail, never a success the process did not achieve.

    /usr/local/bin/saphan-gateway start --config /etc/saphan/config --workspace /srv/saphan/engine --detach

    One instance per user per machine. The guard is an exclusive-create pidfile under the same daemon path ladder saphan serverd uses, at <RuntimeDir>/saphan-gateway.pid; the log is <LogDir>/saphan-gateway.log. On macOS that is ~/Library/Application Support/saphan/run and ~/Library/Logs/saphan; on Linux it is $XDG_RUNTIME_DIR/saphan and $XDG_STATE_HOME/saphan/log. A second start refuses and names the live pid rather than binding a second listener. SAPHAN_RUNTIME_DIR and SAPHAN_LOG_DIR override either slot.

    It also publishes the address it actually bound, at <RuntimeDir>/saphan-gateway.addr — a small JSON object holding the pid, the bound host:port, the ladder rung that chose it, the workspace and the start time. It is written after the listener is up and removed on a clean exit. status READS it (step 3), and a start that cannot write it refuses rather than serving an instance nobody can ask about.

  3. Ask whether it is running — and note that this answers health, not liveness:

    /usr/local/bin/saphan-gateway status --config /etc/saphan/config --workspace /srv/saphan/engine

    It reads the pidfile and probes the listener. A live pid whose listener does not answer prints UNHEALTHY and exits non-zero — that combination is a real failure mode (the process holds its pidfile while every client sees a refused connection) and it is exactly what the metering daemon's own founding incident was.

    The address it probes comes from the record the running instance wrote, never from the listen ladder in your shell. This matters in one very ordinary situation, and until 2026-08-27 it was reported wrong:

    # shell A
    SAPHAN_GATEWAY_LISTEN=127.0.0.1:9000 saphan-gateway start --workspace /srv/saphan/engine --detach
    # shell B — a plain second terminal, no bad flag anywhere
    saphan-gateway status --workspace /srv/saphan/engine

    Shell B does not have that variable, so re-resolving --listenSAPHAN_GATEWAY_LISTEN[gateway] listen → default gave it 127.0.0.1:7656, it probed that, found nothing, and printed UNHEALTHY about a gateway that was serving perfectly. It now reports running and adds a `` line naming the address your own environment would have resolved, so a divergence is visible instead of fatal.

    Three outcomes you will see, and what each means for your hands:

    ReportExitWhat to do
    running (pid N)0Nothing. The listen line is the real address; the `` line, if present, is only telling you your shell would have guessed differently.
    UNHEALTHY — … the address it recorded at start … does not answer1The process is alive and serving nothing. stop, then start again, and read <LogDir>/saphan-gateway.log.
    UNKNOWN — … no instance record names the address it bound1The pid is alive and no address record exists (an instance from before this release, or something deleted the file, or you are asking from a shell whose SAPHAN_RUNTIME_DIR/$HOME resolves a different runtime directory from the one the instance published into — the report names the record path it looked at, so compare it with the address and logs at … directory the starting shell printed on its started (pid N, detached) line). status will not guess an address for you. stop and start again to republish it.

    Add --json when a script reads this instead of a person:

    /usr/local/bin/saphan-gateway status --workspace /srv/saphan/engine --json

    One JSON object, same verdict, same exit code. Branch on healthy for the yes/no and on state (running · unhealthy · not-running · stale-pidfile · no-address-record · record-pid-mismatch) for the cause. listen is the instance's real address; resolved_listen is what your shell's ladder produced, and ladder_diverges is the comparison already made for you — do not re-implement it, that comparison is where the original defect lived.

  4. Check the operational census locally:

    curl --fail --show-error http://127.0.0.1:7656/metrics

    This release reports implemented 9, not_implemented 32, and twelve admitted-door candidates. A missing family or a different total is a deployment/build mismatch.

  5. Read the nine operations that answer, against the workspace you started it for:

    curl -H "$SAPHAN_AUTH" --fail --show-error http://127.0.0.1:7656/v1/gates

    Expect 200 and a JSON object whose pending array holds one entry per gate the fleet owes a human. An empty pending means the record was read and nothing is owed — it is not an error. A 503 with type urn:saphan:gateway:record-unavailable means this process cannot reach its record; the detail names the repair.

    curl -H "$SAPHAN_AUTH" --fail --show-error http://127.0.0.1:7656/v1/overview

    Expect 200 and one object. Read waitsForMe first and read it with its two companions, because this is the number a badge shows and a badge has no room to explain itself:

    • waits_for_me_parts is the same number's two summands — gates (one owed decision per stream, the same rows /v1/gates publishes) and sparks (captured thoughts). If the badge and the gate screen disagree, this field says which half moved.
    • waits_for_me_complete is a positive claim that both halves are counted in full. It is true on this release. If you ever read false, the number is a floor and not a count, and the next field says which half is short.
    • waits_for_me_note states what the number counts and — the part that matters — what it deliberately does not: an owner-block element of kind question is a move only you can make, and it is not in this number, because the contract's sentence for this field says gates and sparks and has no room for it. Read /console/next for those.
**Every spark counts, and here is the one thing to know about that.** `Spark.state` in the

contract is captured | worked, and nothing in this release can write workedcapture_index has no state column and POST /v1/sparks/{sparkId}/work is not implemented. So a thought you jotted down in June and have long since acted on is still in this number, because the record was never told. It is not a bug in the count; it is the record having no word for what you did. The number narrows on its own the day sparks can be marked worked.

streams carries the three counts the contract requires — running, awaitingHuman, merged — plus other, total and other_note, which are not in the contract and are there so you can check the arithmetic: the three published counts do not sum to the fleet, because a stream that was closed without merging, or is planned and never dispatched, or is broken, is in none of them. running + awaitingHuman + merged + other == total, always.

spend.series is one point per calendar UTC day over the last 30 days, ascending, and it is not zero-filled: a day with no ledger row has no point, because "nothing happened" and "things happened and cost nothing" are different facts and only one of them was measured. Use spend.from / spend.to to draw a continuous axis. spend.meter names which meter the amounts are in (cost_real_usd) — on a fleet whose seats are all subscription-covered every amount is a truthful 0, and spend.window_rows is how you tell that from an empty window.

curl -H "$SAPHAN_AUTH" --fail --show-error 'http://127.0.0.1:7656/v1/sparks?window=all&limit=5'

Expect 200, a sparks array newest-first, a nextCursor (a string, or null at the end of the list) and a total_count. Read filters_complete before you read sparks — see the section below for why.

curl -H "$SAPHAN_AUTH" --fail --show-error http://127.0.0.1:7656/v1/policies

Expect 200, a shelves array, a shelvesUnserved array carrying egress with its reason, and shelvesComplete: false. Read shelvesComplete beside any shelf count you show a human — see § GET /v1/policies below for why one shelf is named instead of counted, and for the two different meanings of a 0.

curl -H "$SAPHAN_AUTH" --fail --show-error http://127.0.0.1:7656/v1/fleet/seats

Expect 200, a seats array with one entry per seat on a machine in service, a fields_unserved array with four entries and fields_complete: false. Read every seat's knowledge before you read its numbers — see § GET /v1/fleet/seats below, which is the only place the three answers and the three renewal-instant absences are written down.

Why 503 and not 501

501 Not Implemented is what this condition is, and it is the wrong thing to send. Contract 3.0.1 — the document of the day, and this is the historical measurement that decided the code — declares no 501 response on any of its 39 operations, so a client generated from that document has no type for the body and cannot decode the answer at all. 503 is declared on every operation, so the refusal arrives in a shape the client already understands.

The cost of that choice is one overstatement: 503 says "retry later" about a condition that waiting does not fix, so the gateway sends Retry-After: 3600 rather than a short delay. The type field is the discriminator — urn:saphan:gateway:not-implemented never appears on a real engine outage, so a client can tell "wait for the engine" from "this release does not do that" without guessing. The end state is a 501 response added to the signed contract; that is an owner's act on clients/openapi.yaml, and it is owed.

When an id is not on the record (404, and how to tell it from the two 503s)

The two single-item operations — GET /v1/gates/{gateId} and GET /v1/sparks/{sparkId} — are the first on this surface that can be given something wrong by the caller. Every operation before them took no path parameter, so every failure they had was about the process. An id changes that, and it needs its own answer: 404 with the Problem type urn:saphan:gateway:not-on-the-record, and no Retry-After header, because no amount of waiting turns a stale id into a live one.

Read the type, not the status code. There are now three refusals on this surface and each is fixed by a different person:

typeStatusWhat it meansWho fixes it
urn:saphan:gateway:not-implemented503This release does not serve that operationnobody today — a wagon ships it
urn:saphan:gateway:record-unavailable503This process cannot reach its recordthe operator, in one command
urn:saphan:gateway:not-on-the-record404The record answered; that id is not in itthe caller, by sending a different id

The detail is written for the person holding the 404, and it carries the two things that decide what to do next:

  1. The denominator. GET /v1/gates/{gateId}'s absence says how many gates were owed — 0 gate(s) are owed and none carries that key is a different problem from 47 gate(s) are owed and none carries that key, and only the second one is your id.
  2. Whether the record could ever have held that id. GET /v1/sparks/{sparkId} keeps four absences apart, because each one is a different repair: a decimal id the record does not have · a ULID, which means your client is minting the wrong kind of id (POST /sparks has the client mint one and this gateway does not serve that write, so no ULID has ever reached this record) · a spelling of a real number the list never prints (01, +1, 1), whose sentence names the form that resolves · and a number that is out of range for a row id, which is a well-formed decimal integer and is not reported as a malformed one.

Two things a 404 here does NOT tell you, and both are deliberate.

  • It is not proof the thing does not exist. A row your row grant excludes and a row that was never written answer identically, on both operations — and on GET /v1/sparks/{sparkId} a capture of another kind stored under that id answers identically too, so the 404 does not tell you that something is there. That is the fail-closed shape: the difference between no such thing, not yours and that is a transcript is exactly what an enumeration attack measures, so this gateway does not publish it. Do not read a 404 as an absence proof.
  • On GET /v1/gates/{gateId} it is not even about existence. The id is resolved against the owed-gate queue, the only projection in this engine that carries a pending gate, so a gate the owner has already decided has left that queue and answers exactly as an unknown key does. Every 200 from that operation carries the same sentence in its note field, so a client never has to have read this page.
  1. Check one refusing contract route and inspect its named refusal body:

    curl --show-error --include http://127.0.0.1:7656/v1/capabilities

    The expected status in this release is 503 with a Retry-After: 3600 header, and JSON fields type (urn:saphan:gateway:not-implemented), title, and a detail naming GET /v1/capabilities. A 404 means the contract router is not installed. A 503 whose type is not that URN is a real engine problem, not this release's missing adapter.

  2. Stop it:

    /usr/local/bin/saphan-gateway stop

    It sends SIGTERM to the pid its own pidfile names — so it cannot stop the wrong process, and it needs no flags. A supervisor's own stop, or SIGTERM/SIGINT to a foreground process, does the same thing; all three begin the same five-second graceful shutdown, and the pidfile is removed on the way out. Do not delete any workspace data: the gateway created none.

    If stop says not running (no pidfile) or not running (stale pidfile, pid N gone), the process is already down — the second message means it did not shut down cleanly and left its pidfile behind, which the next start takes over automatically.

Startup refusals

What it saysWhat to do
--workspace is requiredPass the absolute workspace served by this instance.
--workspace must be an absolute pathReplace a relative spelling with the full path.
names a port but no addressWrite 127.0.0.1:7656, not :7656.
is not loopbackBind the gateway to loopback and terminate public TLS in a local proxy.
[gateway] unknown keyCorrect the key; the only key is listen.
`serve` is goneUse start (add --detach to background it), stop or status. The refusal prints all three.
a live saphan-gateway instance is already running (pid N)One instance per user per machine. saphan-gateway stop, then start again.
the daemon path ladder resolved no runtime or log directorySet $HOME, or set SAPHAN_RUNTIME_DIR and SAPHAN_LOG_DIR explicitly.
overview.css block … hashes to … (at start)The extracted mockup CSS was edited by hand. Re-extract it from the mockup, or revert; the process will not serve CSS it cannot attribute.
the screen inventory declares no web screen … (at start)A consoleassets/<id>.screen.tmpl was added for an id screens.json does not carry on the web surface. Declare the screen in the inventory, or rename the template; a template is not a licence to render a frame the mockup does not have.

| instance record … (at start) | The gateway could not publish <RuntimeDir>/saphan-gateway.addr, so status would have nothing to read; it refuses rather than serve unaskable. Check the runtime directory's permissions, or set SAPHAN_RUNTIME_DIR. |

There is no repair command that enables the three candidate verbs. They need reviewed, lossless engine-door contracts first; changing a refusal to success by discarding signed fields is not remediation.