Node-agent internal: report one scan's result. (Not operator-facing.)
const url = 'http://localhost:8080/v1/agent/library/scan-report';const options = { method: 'POST', headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'}, body: '{"scan_id":"2489E9AD-2EE2-8E00-8EC9-32D5F69181C0","ok":true,"error":"example","entries":[{"external_id":"example","name":"example","install_dir":"example","size_on_disk":1,"state_flags":1}]}'};
try { const response = await fetch(url, options); const data = await response.json(); console.log(data);} catch (error) { console.error(error);}use serde_json::json;use reqwest;
#[tokio::main]pub async fn main() { let url = "http://localhost:8080/v1/agent/library/scan-report";
let payload = json!({ "scan_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "ok": true, "error": "example", "entries": ( json!({ "external_id": "example", "name": "example", "install_dir": "example", "size_on_disk": 1, "state_flags": 1 }) ) });
let mut headers = reqwest::header::HeaderMap::new(); headers.insert("Authorization", "Bearer <token>".parse().unwrap()); headers.insert("Content-Type", "application/json".parse().unwrap());
let client = reqwest::Client::new(); let response = client.post(url) .headers(headers) .json(&payload) .send() .await;
let results = response.unwrap() .json::<serde_json::Value>() .await .unwrap();
dbg!(results);}curl --request POST \ --url http://localhost:8080/v1/agent/library/scan-report \ --header 'Authorization: Bearer <token>' \ --header 'Content-Type: application/json' \ --data '{ "scan_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "ok": true, "error": "example", "entries": [ { "external_id": "example", "name": "example", "install_dir": "example", "size_on_disk": 1, "state_flags": 1 } ] }'Phase 4. Accepting an ok:true report runs the whole reconciliation IN ONE TRANSACTION with marking the scan reported - observations upserted and pruned, the suppression ladder evaluated ONCE, tiles created for what publishes, tiles disabled and their provider entitlements revoked for what suppresses, and the scanning user’s provider entitlements granted and revoked. Tile creation and the first entitlement commit TOGETHER, because auto-publish means they are no longer separated by an admin decision and a tile that exists with no entitlement is a window in which a user sees nothing where the feature just claimed to add something. Scan-observability amendment (2026-08-01): reconciliation additionally BACKFILLS existing tiles of this parent whose enrichable fields are EMPTY (description, initially), from the same appdetails source the suppression rung uses - gated by the SAME appdetails switch (off = no backfill, no third-party call), bounded per scan, and FILL-BLANKS-ONLY: a non-empty field is never overwritten, so an operator’s edit survives every scan. This is why “Scan now” is also the “fetch newly-supported data for what I already have” button - as enrichment capabilities grow, a re-scan tops up blanks instead of requiring tiles to be deleted and re-discovered. The per-scan outcome counts (including backfilled) are stored on the scan row (migration 0048) and surfaced via LibraryStatus.recent_scans. An ok:false report CHANGES NOTHING BUT ITS OWN ROW: revocation is driven by absence, and absence is exactly what a transient error looks like, so “reconcile whatever entries did arrive” would make a partial walk indistinguishable from an uninstall. 404 for a scan the calling host does not own - indistinguishable from one that does not exist, deliberately, since a 403 would confirm another host’s scan id to anyone holding one valid node secret. 409 for a scan that is not currently claimed, which is where a duplicate report after a successful reconcile lands.
Authorizations
Section titled “Authorizations”Request Bodyrequired
Section titled “Request Bodyrequired”object
False reports a failure - a refused path, no configured home root, a walk that could not complete. A FAILED SCAN CHANGES NOTHING BUT ITS OWN ROW: revocation is driven by ABSENCE, and absence is exactly what a transient error looks like, so “reconcile whatever entries did arrive” would make a partial walk indistinguishable from an uninstall.
Failure text on ok:false. Omitted when empty; truncated server-side.
Omitted when empty. An absent entries on an ok:true report is a legitimate “this user has nothing installed” and is reconciled as such - which is how an uninstall of the last game is recognised.
One parsed appmanifest_*.acf. THE FIELD SET IS THE PII CONTAINMENT MECHANISM, not a convenience. Every manifest carries LastOwner, a SteamID64 - a persistent, globally unique, externally resolvable identifier for a real person’s Steam account. The agent parses with a key ALLOW-LIST (appid, name, installdir, SizeOnDisk, StateFlags) and NEVER a denylist, because Steam adds keys over time and a denylist leaks every future key by default; and this shape has exactly those five fields, so THERE IS NOWHERE FOR A SIXTH VALUE TO TRAVEL even if the parser were wrong. Widening either the allow-list or this shape is the change that sentence exists to make someone stop and think about.
object
The Steam appid. Validated at FOUR independent points - agent parse, control-plane ingest (plus a bound below 2^32), the database CHECK, and the launch-time flag composer - because the value is written by an automated job and read by a shell-adjacent consumer (STEAM_STARTUP_FLAGS is word-split by read -r -a), and because only the CHECK survives a later admin edit. An entry failing ingest validation is REJECTED WHILE THE REST OF THE REPORT IS STILL PROCESSED: one malformed manifest must not cost a user their library.
The manifest title. Retained deliberately, and its importance went UP rather than down: it is the tile title AND half of the denylist key (which matches on appid OR name prefix), so it is load-bearing for correctness. It is a game title, not a person.
COLLECTED AND NOT USED. It existed to feed launch verification, which operator decision 3 dropped, and it has had no consumer since. It stays because narrowing a PII containment allow-list buys nothing while widening it later means re-touching the one piece of code whose job is to touch as little of the manifest as possible. Do not assume it is populated for some other purpose.
COLLECTED AND NOT USED - never consumed by anything in this feature. Kept for the same reason as install_dir.
Unread, and the live capture is why: it was 4 for all five Valve tools on the reference box AND for three of the four real games. NO STRUCTURAL FIELD DISTINGUISHES A TOOL FROM A GAME, which is exactly why filtering is on appid and name instead. Nothing branches on this.
Examplegenerated
{ "scan_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "ok": true, "error": "example", "entries": [ { "external_id": "example", "name": "example", "install_dir": "example", "size_on_disk": 1, "state_flags": 1 } ]}Responses
Section titled “Responses”Report accepted (and reconciled
object
Examplegenerated
{ "accepted": true}Malformed or invalid request.
object
object
E.g. validation_failed, unauthorized, forbidden, not_found, conflict, session_quota_exceeded, home_in_use, home_not_provisioned, parent_app_disabled, profile_ineligible, profile_not_launchable_for_app, no_host_available, capacity_exhausted, restart_required, rate_limited, internal. Open string, not an enum: new codes are additive and an unknown one falls through to a client’s generic per-status branch.
Present on restart_required.
Steam library discovery Phase 3, ADDITIVE: present on home_in_use when the guard could name the CONFLICTING live session - the one already holding the home. It is here so the client can offer “go to your running session” with a link instead of a dead-end toast. OMITTED rather than empty when the conflict is known but the session is not, so a client branches on presence and never renders a link to nowhere. Load-bearing once derived tiles exist: the lock is held by the PARENT’s home, so a user who clicks a game tile can be refused because a DIFFERENT app (the Steam launcher, or another game from the same install) is running, and without the session id the refusal reads as a bug. See control-api.md §Derived tiles.
Steam library discovery Phase 3, ADDITIVE: present on the 409 conflict from DELETE /v1/apps/{id} when the app has derived tiles and ?delete_derived=true was not sent. A LIST, not a count - the point of the confirmation is that the admin sees what they are about to destroy. Capped; an empty array means the tiles could not be listed, never that there are none.
object
Examplegenerated
{ "error": { "code": "example", "message": "example", "live_sessions": 1, "session_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "derived_tiles": [ { "id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "name": "example" } ] }}Missing/invalid/expired/revoked token.
object
object
E.g. validation_failed, unauthorized, forbidden, not_found, conflict, session_quota_exceeded, home_in_use, home_not_provisioned, parent_app_disabled, profile_ineligible, profile_not_launchable_for_app, no_host_available, capacity_exhausted, restart_required, rate_limited, internal. Open string, not an enum: new codes are additive and an unknown one falls through to a client’s generic per-status branch.
Present on restart_required.
Steam library discovery Phase 3, ADDITIVE: present on home_in_use when the guard could name the CONFLICTING live session - the one already holding the home. It is here so the client can offer “go to your running session” with a link instead of a dead-end toast. OMITTED rather than empty when the conflict is known but the session is not, so a client branches on presence and never renders a link to nowhere. Load-bearing once derived tiles exist: the lock is held by the PARENT’s home, so a user who clicks a game tile can be refused because a DIFFERENT app (the Steam launcher, or another game from the same install) is running, and without the session id the refusal reads as a bug. See control-api.md §Derived tiles.
Steam library discovery Phase 3, ADDITIVE: present on the 409 conflict from DELETE /v1/apps/{id} when the app has derived tiles and ?delete_derived=true was not sent. A LIST, not a count - the point of the confirmation is that the admin sees what they are about to destroy. Capped; an empty array means the tiles could not be listed, never that there are none.
object
Examplegenerated
{ "error": { "code": "example", "message": "example", "live_sessions": 1, "session_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "derived_tiles": [ { "id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "name": "example" } ] }}No such resource.
object
object
E.g. validation_failed, unauthorized, forbidden, not_found, conflict, session_quota_exceeded, home_in_use, home_not_provisioned, parent_app_disabled, profile_ineligible, profile_not_launchable_for_app, no_host_available, capacity_exhausted, restart_required, rate_limited, internal. Open string, not an enum: new codes are additive and an unknown one falls through to a client’s generic per-status branch.
Present on restart_required.
Steam library discovery Phase 3, ADDITIVE: present on home_in_use when the guard could name the CONFLICTING live session - the one already holding the home. It is here so the client can offer “go to your running session” with a link instead of a dead-end toast. OMITTED rather than empty when the conflict is known but the session is not, so a client branches on presence and never renders a link to nowhere. Load-bearing once derived tiles exist: the lock is held by the PARENT’s home, so a user who clicks a game tile can be refused because a DIFFERENT app (the Steam launcher, or another game from the same install) is running, and without the session id the refusal reads as a bug. See control-api.md §Derived tiles.
Steam library discovery Phase 3, ADDITIVE: present on the 409 conflict from DELETE /v1/apps/{id} when the app has derived tiles and ?delete_derived=true was not sent. A LIST, not a count - the point of the confirmation is that the admin sees what they are about to destroy. Capped; an empty array means the tiles could not be listed, never that there are none.
object
Examplegenerated
{ "error": { "code": "example", "message": "example", "live_sessions": 1, "session_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "derived_tiles": [ { "id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "name": "example" } ] }}Conflict (duplicate, in-use, quota, non-swappable, home-in-use, …).
object
object
E.g. validation_failed, unauthorized, forbidden, not_found, conflict, session_quota_exceeded, home_in_use, home_not_provisioned, parent_app_disabled, profile_ineligible, profile_not_launchable_for_app, no_host_available, capacity_exhausted, restart_required, rate_limited, internal. Open string, not an enum: new codes are additive and an unknown one falls through to a client’s generic per-status branch.
Present on restart_required.
Steam library discovery Phase 3, ADDITIVE: present on home_in_use when the guard could name the CONFLICTING live session - the one already holding the home. It is here so the client can offer “go to your running session” with a link instead of a dead-end toast. OMITTED rather than empty when the conflict is known but the session is not, so a client branches on presence and never renders a link to nowhere. Load-bearing once derived tiles exist: the lock is held by the PARENT’s home, so a user who clicks a game tile can be refused because a DIFFERENT app (the Steam launcher, or another game from the same install) is running, and without the session id the refusal reads as a bug. See control-api.md §Derived tiles.
Steam library discovery Phase 3, ADDITIVE: present on the 409 conflict from DELETE /v1/apps/{id} when the app has derived tiles and ?delete_derived=true was not sent. A LIST, not a count - the point of the confirmation is that the admin sees what they are about to destroy. Capped; an empty array means the tiles could not be listed, never that there are none.
object
Examplegenerated
{ "error": { "code": "example", "message": "example", "live_sessions": 1, "session_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "derived_tiles": [ { "id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "name": "example" } ] }}Could not record or reconcile the report.
object
object
E.g. validation_failed, unauthorized, forbidden, not_found, conflict, session_quota_exceeded, home_in_use, home_not_provisioned, parent_app_disabled, profile_ineligible, profile_not_launchable_for_app, no_host_available, capacity_exhausted, restart_required, rate_limited, internal. Open string, not an enum: new codes are additive and an unknown one falls through to a client’s generic per-status branch.
Present on restart_required.
Steam library discovery Phase 3, ADDITIVE: present on home_in_use when the guard could name the CONFLICTING live session - the one already holding the home. It is here so the client can offer “go to your running session” with a link instead of a dead-end toast. OMITTED rather than empty when the conflict is known but the session is not, so a client branches on presence and never renders a link to nowhere. Load-bearing once derived tiles exist: the lock is held by the PARENT’s home, so a user who clicks a game tile can be refused because a DIFFERENT app (the Steam launcher, or another game from the same install) is running, and without the session id the refusal reads as a bug. See control-api.md §Derived tiles.
Steam library discovery Phase 3, ADDITIVE: present on the 409 conflict from DELETE /v1/apps/{id} when the app has derived tiles and ?delete_derived=true was not sent. A LIST, not a count - the point of the confirmation is that the admin sees what they are about to destroy. Capped; an empty array means the tiles could not be listed, never that there are none.
object
Examplegenerated
{ "error": { "code": "example", "message": "example", "live_sessions": 1, "session_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "derived_tiles": [ { "id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "name": "example" } ] }}