ADR-019. An API name filter narrows a listing, it never selects a resource

Status: accepted, 10 September 2026. Still in force.

Context

Half of this collection acts on a sub-resource carrying its own identifier, which only the matching _info module returns. The README publishes the measurement: the inventory sets scaleway_id and scaleway_zone, which is what 25 of the 50 modules need, and the other 25 act on a sub-resource whose identifier comes from a listing. So a playbook calls a module, registers its result, digs a UUID out of it, and injects it into the next task.

The shape that appears in every such playbook is the problem:

backend_id: "{{ (backends.backends | first).id }}"

| first is silent when there are two candidates and crashes obscurely when there are none. Removing it was the reason to resolve a name into an identifier at all.

The obvious implementation is to let the API do the work, because the versioned contracts declare a name query parameter on the listings. Measured on the contracts, that filter does not mean what its name suggests. ListServers documents itself:

Filter Instances by name (eg. “server1” will return “server100” and “server1” but not “foo”).

and ListVolumes:

for eg. “vol” will return “myvolume” but not “data”.

It is a prefix match. Three levels of declaration coexist for one notion: ListServers and ListVolumes describe the semantics with an example, ListLbs and ListBackends say only “to filter for”, and ListImages declares nothing at all. Nothing in the contracts lets us conclude that the filter is exact anywhere.

A lookup that trusted it would answer server100 to a caller asking for server1. That is the | first defect moved inside the plugin, where it is less visible: the playbook would not even show the list it chose from.

Decision

The API filter is a narrowing hint, never a selection.

  • it is sent when the contract declares it, because paginating a whole fleet to find one machine is a cost the caller did not ask for;

  • the exact comparison on name is done locally, in every case, including when the API has already filtered. A code path that only ran against some future contract would not be exercised, and an unexercised path is a promise;

  • the three outcomes are distinct and none of them is a choice made on the caller’s behalf:

    no exact match     -> refuse, naming what was found nearby
    exactly one        -> its identifier
    more than one      -> refuse, naming the candidates
    

The “no match” message carries the near misses, because the question the caller is asking at that moment is whether they made a typo. The “several” message carries the identifiers, because without them there is nothing to arbitrate. Scaleway does not enforce name uniqueness, so the third case is not theoretical.

Consequences

Resolving one name costs at least one listing, and the local comparison walks every element the API returned. On a large fleet with a filter that narrows poorly, that is more work than a single GET. It is the price of never answering with a resource nobody designated.

A caller who genuinely wants prefix behaviour cannot get it from this lookup. That is deliberate: they can call the _info module and filter as they see fit, where the list they chose from is visible in the playbook.

The refusal messages become part of the interface, so they are worth their length. A message that said only “not found” would send someone hunting for a typo in a name that is spelled correctly.

What this record does not decide

Which identifiers can be resolved at all. That is derived from the contracts, not decided: an identifier resolves when exactly one listing returns an object carrying both id and name. Four identifiers of the versioned contracts do not, because Ip, Route, PrivateNIC and SecurityGroupRule carry no name field, and no heuristic will fill that in. They are published with that reason rather than approximated.

How a retry policy applies to the listing, which is ADR-018.