feat(host): capture unclaimed request bodies; record the consumable-apply wire

Milestone 2: the consumable-apply protocol is now LIVE_PROVEN.

Adds opt-in passthrough BODY logging (OPENFUT_FIFA17_LOG_PASSTHROUGH_BODY=1,
default off, capped at 512 bytes) because a body is what names an unknown
mutation's operands, while also being the one place a request could carry
something that should not reach a log. Staging probe only.

With it, one operator apply captured the whole thing:

    POST /ut/game/fifa17/item/resource/5001004
    {"apply":[{"id":100000003}]}

  source consumable : resource 5001004 (player contract, subtype 201) -- in the PATH
  target item(s)    : wire 100000003 (squad slot 0 GK, resourceId 200389) -- body apply[]
  verb              : POST

There is NO /apply endpoint, exactly as the static route work concluded. The
apply re-uses `ut/%s/item/resource`, which we already serve for GET (definition
lookup); the POST verb on that path is the mutation and nothing claimed it. This
is the wire form of the ApplyCardByRes task (id 0x0e), which is why the source is
a definition id rather than an instance id. `apply` is an array, so one resource
can name several targets.

Fail-closed verified: with the upstream dead the request 502s and Core is left
exactly unchanged -- coins 29,843,976, owned 1993, consumables 17, source card
still owned. No partial mutation.

NOT implemented: the response shape is unobserved and the EFFECT is unreversed.
Our catalog carries contract:7 for 5001004, documented as the matches granted,
but that is observed profile data (INFERRED), so no effect is written on it.

Bonus, caught by the same logging: the client really does request
`club/consumables/development`, which has no arm in
consumable_families_for_category and is served empty. Recorded, not guessed.

Host 120 lib tests, fmt clean.
This commit is contained in:
funman300
2026-08-22 00:23:15 +00:00
parent db5fb37980
commit 739228efdb
2 changed files with 79 additions and 2 deletions
+53
View File
@@ -136,3 +136,56 @@ host now names every unclaimed request:
```
utas-host owner=PYTHON route=passthrough method=GET path=/ut/... body_len=N
```
## CONSUMABLE APPLY — LIVE_PROVEN (2026-08-21)
Captured end to end on staging, operator applying a bronze player contract:
```
POST /ut/game/fifa17/item/resource/5001004
{"apply":[{"id":100000003}]}
```
| element | value | where |
|---|---|---|
| source consumable | resource id `5001004` (player contract, subtype 201) | **path** |
| target item(s) | wire instance `100000003` (= squad slot 0 GK, resourceId 200389) | **body**, `apply[]` |
| verb | `POST` | |
**There is no `/apply` endpoint** — the apply re-uses `ut/%s/item/resource`, which
we already serve for **GET** (item-definition lookup). The **POST** verb on that
path is the mutation, and nothing claimed it, so it fell through to Python. This
is the wire form of the `ApplyCardByRes` task (id `0x0e`) -- "apply card **by
res**ource" -- which is why the source is a definition id rather than an instance
id.
`apply` is an ARRAY, so one consumable resource can name several targets in a
single request. Whether the client ever batches is unobserved.
Corroborating UI evidence from the same session: applying to a PLAYER offered
only the subtype-201 card and withheld both subtype-202 manager contracts,
independently confirming the `201 = player_contract / 202 = manager_contract`
split.
Fail-closed confirmed: with the upstream dead the request 502s and Core is left
EXACTLY unchanged (coins, owned count, and the source card all identical).
### Not yet known
* the **response shape** the client expects on success;
* the **effect** -- how many matches a contract grants. Our own catalog carries
`contract: 7` for `5001004`, documented as "the number of matches the card
grants", but that is observed profile data, i.e. INFERRED, not reversed. No
effect is implemented on that basis.
## Consumables category `development` is unmapped (client really asks)
The new passthrough/route logging caught the client requesting
```
GET /ut/game/fifa17/club/consumables/development -> outcome=unknown_category emitted=0
```
`consumable_families_for_category` has no `development` arm, so the screen is
served empty. The client demonstrably asks for it, which is exactly the condition
that function's own doc says should add an arm. Which families it should map to
is NOT guessed here.
+26 -2
View File
@@ -4025,12 +4025,23 @@ impl Server {
// Name the request BEFORE forwarding. On staging the upstream is
// deliberately dead, so this line is the only record of what the
// client asked for -- which is exactly how an unclaimed route is
// discovered (see docs/CLIENT_ROUTE_SURFACE.md). Bodies are not
// logged; the path and method are enough to identify a route.
// discovered (see docs/CLIENT_ROUTE_SURFACE.md).
eprintln!(
"utas-host owner=PYTHON route=passthrough method={method} path={target} body_len={}",
body.len()
);
// The BODY is what identifies an unknown mutation's operands, but
// it is also the one place a request can carry something we should
// not write to a log, so it is opt-in and capped. Staging probe
// only: OPENFUT_FIFA17_LOG_PASSTHROUGH_BODY=1.
if passthrough_body_logging() && !body.is_empty() {
let cap = body.len().min(512);
eprintln!(
"utas-host PASSTHROUGH-BODY path={target} bytes={} body={}",
body.len(),
String::from_utf8_lossy(&body[..cap])
);
}
let resp = match self.pass.forward(method, target, headers, body) {
Ok(r) => r,
Err(e) => {
@@ -4865,6 +4876,19 @@ fn commerce_settings_enabled() -> bool {
*ENABLED.get_or_init(|| std::env::var("OPENFUT_FIFA17_COMMERCE_SETTINGS").as_deref() == Ok("1"))
}
/// Whether to log unclaimed (passthrough) request BODIES.
///
/// OFF unless `OPENFUT_FIFA17_LOG_PASSTHROUGH_BODY=1`, and capped at 512 bytes.
/// A body is what names an unknown mutation's operands -- it is how the
/// consumable-apply payload was recovered -- but it is also the one place a
/// request could carry something that should not reach a log, so it is opt-in
/// and intended for staging probes only.
fn passthrough_body_logging() -> bool {
static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ENABLED
.get_or_init(|| std::env::var("OPENFUT_FIFA17_LOG_PASSTHROUGH_BODY").as_deref() == Ok("1"))
}
/// Whether quick-sell pricing uses the client's own `fcc_discardcoins` table
/// instead of the legacy rating-only ladder.
///