REC registry client
celine.sdk.rec_registry. Six repositories import it. It wraps the generated registry
client so callers see short method names and Pydantic schemas instead of Response
objects — the general shape of every wrapper in this SDK.
What is stated here is only the part of that wrapper where a wrong answer is indistinguishable from a right one: the three batch lookups, whose empty list is a real answer the service gives on purpose. The rest of the wrapper — every single-id lookup, the writes, the user-scoped client — is not specified yet, except the meter and profile writes (REQ-0125–REQ-0128), the area and topology writes of onboarding's template sync (REQ-0129–REQ-0131, REQ-0133), the per-field member writes and the delivery-point writes (REQ-0134, REQ-0135), the duplicate delivery points read (REQ-0136), and how the self-service reads raise a registry refusal (REQ-0132), all at the end of this page.
The service's own behaviour belongs to rec-registry, not here; where a requirement below
mirrors one of its, the identifier is named so the two can be kept honest.
Batch asset lookups
lookup_assets_by_sensor_ids and lookup_assets_by_user_ids are mirrors: one starts from
a device and finds its owner, the other starts from owners and finds their devices.
Both sit in front of routes where an empty list means something. A sensor id that
matches nothing contributes no row (rec-registry REQ-0043); a user id belonging to nobody
and a member who owns nothing are deliberately indistinguishable, so that the route
cannot be used to discover who is registered (rec-registry REQ-0045). Everything below
follows from that: the wrapper must never add a third meaning to a sentence that already
has two.
REQ-0120 — a refused batch lookup raises, and never answers an empty list
A response the generated client does not parse into a list of assets — a 422 parsed as
HTTPValidationError, or any status parsed as None — raises RecRegistryApiError
carrying the status code and the response body.
Returning [] there is the failure this requirement exists to prevent: it fails in the
direction that loses data quietly, because the caller is told, in the only language the
method has, that nothing matched. A consumer resolving six hundred sensor ids for a
dataspace query would conclude that none of them are registered.
The exception is the wrapper's own, not the generated layer's, and mirrors
celine.sdk.dt.util.DTApiError.
The same exception also carries the registry's machine-readable refusal code and its
detail (REQ-0127). The batch lookups set neither — their refusals are validation errors,
which name no code — so both are None there.
REQ-0121 — a batch larger than the bound is split, not refused
Both routes accept at most MAX_BATCH_LOOKUP_IDS ids — 500, mirrored from rec-registry,
where REQ-0043 and REQ-0045 read one shared constant of that name and 501 is a 422.
The wrapper splits a longer input into consecutive requests of at most that many ids and concatenates the rows in request order. Chunking is invisible to the caller: the bound is a property of the route, not a mistake the caller made.
The number is named once. It has been written twice before, in the service, and the two
copies disagreed for months (rec-registry#37).
REQ-0122 — an empty batch asks nothing
No ids means no request and an empty list, matching the service, which answers an empty list without querying.
REQ-0123 — the older singular name still works
lookup_asset_by_sensor_ids — singular asset — remains as a deprecated alias
delegating to lookup_assets_by_sensor_ids. It is the name digital-twin calls, and this
SDK reaches its consumers through a version bump with no file in those repositories
changing, so a removed method fails at runtime rather than at build.
The DID batch
REQ-0124 — the DID batch answers members, and shares the bound and the refusal rule
lookup_members_by_dids resolves a set of dataspace DIDs to the members holding them,
across communities. It is the join between the connector's answer to who consented —
stated in DIDs — and the registry's answer to what they hold (rec-registry REQ-0061).
It answers members, not assets, and that is the requirement rather than an
implementation detail. Onboarding writes a participant's declared supply point onto the
member and registers no asset, because a meter's sensor_id is assigned at physical
installation — so an asset-shaped answer is empty for every participant whose meter is not
yet commissioned, which is most of the population a consent-gated export covers. Every row
therefore carries delivery_points, and its did, which is what lets the caller attribute
a row back to the DID it asked about.
It shares the batch helper with the two asset lookups and therefore shares REQ-0120 (a
refusal raises rather than answering an empty list), REQ-0121 (a batch over
MAX_BATCH_LOOKUP_IDS is split and concatenated in request order) and REQ-0122 (an empty
batch asks nothing). Sharing is the point: three routes with the same empty-list hazard and
one implementation of it is why a fourth cannot quietly get it wrong.
A DID belonging to nobody and a member holding no supply points are deliberately
indistinguishable at the service (rec-registry REQ-0061), and the wrapper adds no third
meaning.
Meter and profile writes
A community manager attaches a member's meter, detaches it, and corrects the member's role
and area, from the community dashboard. Its backend calls the registry through this wrapper
with a token carrying only the scope that one write needs — rec-registry.assets.write for a
meter, rec-registry.members.profile.write for a profile — so the helpers below take the
token per call, as every other method does.
These helpers differ from the existing writes on purpose. create_member, patch_member,
upsert_asset and their siblings return the undecoded response, because for their callers a
409 is an ordinary outcome to branch on (a retried registration finds the member already
there). They keep that contract; onboarding depends on it. For the dashboard a refusal is an
answer to show the manager, and the registry names it with a code — so the helpers below
parse success into a schema and raise on everything else, and the code is the part of the
refusal the caller acts on.
The rules they rely on belong to rec-registry: the meter asset key meter-<sensor_id>
with the sensor id trimmed, one active holder per sensor id across communities, detaching as
a hard delete of the asset, and the dedicated profile route and its action. They are named
here only so the two can be kept honest.
REQ-0125 — attaching a meter writes one asset through the per-asset route
The meter write (RecRegistryAdminClient.put_asset) sends one asset to PUT /admin/communities/{community_key}/members/{member_key}/assets/{asset_key},
with the asset key and body the caller gives and nothing added, and answers the stored asset
as the generated AssetDetailSchema — the shape the asset GET answers, which the registry
declares on the PUT too since 1.6.0 (the wrapper declares no schema of its own; a key the
registry adds later is ignored, not refused). It does not compose the key and does not trim the sensor id: the key convention
and the comparison are the registry's, and a second copy of either here would be the one
that drifts.
Writing an asset the member already holds with the same body answers the stored asset, as the registry does; the wrapper does not turn a no-op into an error.
This is what fills the gap REQ-0124 describes: onboarding registers no asset, and a meter reaches a member only when a manager attaches it.
REQ-0126 — detaching a meter deletes that one asset
The meter delete (RecRegistryAdminClient.delete_asset) sends DELETE for one asset key of one member and answers nothing on
204. The member's other assets are not touched. Any other status raises under REQ-0127 —
including a 404 for a key the member does not hold, which the wrapper does not read as
"already detached": whether that is success is the caller's decision, not a translation the
wrapper makes silently.
REQ-0127 — a refused write raises with the registry's code
Extends REQ-0120 to the helpers of this section. A status the helper does not treat as
success — declared in the registry's spec or not — raises RecRegistryApiError carrying the
status code, the body and, when the registry's refusal names one, its machine-readable
code as a string (sensor_held, asset_key_too_long, invalid_area_boundary, and the
rest of the registry's error-code vocabulary). A refusal that names no code has code of
None.
The registry declares its coded 422 beside FastAPI's validation 422 as one oneOf
body (ErrorResponse / HTTPValidationError). The generated parse tries ErrorResponse
first and accepts a validation body as one too, with no code, so the model it returns
does not tell the two apart: the code is always read from the raw body, as below.
The registry's refusal body is flat: {"detail": "<sentence>", "code": "<code>"}, where
detail is always a string and code sits beside it at the top level. The helper reads
code from there and never parses the sentence; detail is kept for people. This is the
registry's shape, not onboarding's — onboarding nests its code inside detail — and the two
wrappers each read the shape their own service emits. The generated
layer's own "unexpected status" error never reaches the caller, and neither does its
parse of code into the generated ErrorCode enum: the helpers read the response
themselves. A body that is not JSON (a proxy's page) raises the same exception with
code and detail of None; an unreadable 200 raises rather than answering nothing.
The code is compared as a string and never mapped to an enum here: the vocabulary is the
registry's, a new code must not fail a caller built against the old list, and a generated
enum never compares equal to its string. sensor_held in particular is a normal answer —
another active member, in this community or another, holds the sensor — and the dashboard
shows it to the manager; the wrapper adds nothing to it, and it carries no member or
community the registry did not name.
The exception gains code and detail, keyword-only and defaulting to None, without
losing status_code or body, so a caller of the batch lookups that catches it is
unchanged.
REQ-0128 — the profile write sends only role and area, to the profile route
The profile write (RecRegistryAdminClient.patch_member_profile) sends PATCH /admin/communities/{community_key}/members/{member_key}/profile
with a body of only the keys the caller set, out of role and area. It has no parameter
for any other member field, so it cannot send one, and an omitted key is omitted rather than
sent as null (the same rule as REQ-0111). It answers the updated member as a schema, and a
refusal raises under REQ-0127 (invalid_role and unknown_area are 422; a missing
member or community is 404). Called with neither key, it raises ValueError and sends
nothing: the registry refuses an empty body with a validation 422 that carries no code, so
there would be nothing to show. It passes the values as given — which roles a caller may set
is that caller's rule (the dashboard's backend allows only consumer and prosumer), and the
role and area sets are the registry's (rec-registry REQ-0066, REQ-0070).
It never falls back to the general member PATCH: that route needs
rec-registry.members.write, which the dashboard's backend is deliberately not granted,
because it would let it rewrite a member's user_id, did and status.
Areas and topology: the onboarding template sync
Onboarding's templates are the source of truth for a community's areas, and onboarding
writes them to the registry: each primary substation as one topology node, then the area
that references it (rec-registry REQ-0067, REQ-0072). The sync and its drift check read
the community first to know what is there. For them a refusal is a row in a report to an
admin — an area that breaks the one-substation rule, an area members still use, a node an
area still lists — so these helpers follow the raising helpers above rather than the
undecoded writes: success is a schema, anything else raises RecRegistryApiError under
REQ-0127, with the registry's code read from the raw body.
The token is passed per call, as everywhere in this wrapper: onboarding requests its
optional write scope (rec-registry.community.write) only for the writes, and its read with
the default one. What is compared, created, pruned or refused is onboarding's to decide; the
wrapper adds no step, retries nothing and deletes nothing it was not asked to.
REQ-0129 — the community read answers areas and topology, or raises
The community read (RecRegistryAdminClient.read_community) sends
GET /admin/communities/{community_key} and answers the generated CommunityDetailSchema:
each area with its boundary (source, id) and topology, and each topology node with
id, type, name, operator_id and parent — the names the registry reads and writes
since 1.6.0. Anything but 200 raises RecRegistryApiError, including a missing community
(404, which this route answers without a code and does not declare) and a missing grant
(403), so a community that could not be read is never taken for one with no areas. An
unreadable 200 raises too.
get_community, which answers the undecoded response, is unchanged; its callers depend on
that.
REQ-0130 — an area write sends one area and answers the community
The area write (RecRegistryAdminClient.put_area) sends
PUT /admin/communities/{community_key}/areas/{area_key} with the body the caller gives —
{name, boundary: {source, id}, topology: [id]} — and nothing added. The area delete
(RecRegistryAdminClient.delete_area) sends DELETE for one area key. Both answer the whole
community as CommunityDetailSchema, as the registry does, so the caller sees the areas it
did not touch.
A refusal raises under REQ-0127: invalid_area_boundary (422) on the write,
area_in_use (409) on the delete — whose sentence names the member count, which the
wrapper keeps in detail and does not parse — and community_not_found (404). A 404
with no code for an area the community does not have is raised, not read as "already
deleted": whether that is success is the caller's decision. The one-substation rule, and
that the area's node must exist before the area, are the registry's; the wrapper checks
neither.
REQ-0131 — a topology node write sends one node and answers the community
The node write (RecRegistryAdminClient.put_topology_node) sends
PUT /admin/communities/{community_key}/topology/{node_id} with the body the caller gives —
{id, type, name?, operator_id?, parent?}. It does not fill the body's id from the path or
the other way round: the registry refuses a mismatch with a validation 422, and the wrapper
lets that refusal through rather than hiding it. The node delete
(RecRegistryAdminClient.delete_topology_node) sends DELETE for one node id. Both answer
the whole community as CommunityDetailSchema.
A refusal raises under REQ-0127: invalid_area_boundary (422, the write would change the
type of a node an area relies on), topology_node_in_use (409, an area lists the node;
the sentence names the areas), community_not_found (404), and a 404 with no code for a
node the community does not have, which is not read as "already deleted".
REQ-0133 — an area rename sends only the new key and answers what moved
The area rename (RecRegistryAdminClient.rename_area) sends
POST /admin/communities/{community_key}/areas/{area_key}/rename with a body of only
{"new_key": <new key>}, and answers the generated AreaRenamedSchema: old_key,
new_key, members_moved and the whole community after the rename, as the registry answers
it (rec-registry REQ-0079). It is one request: the wrapper does not follow it with an area
PUT or DELETE of its own, and it does not check the key's pattern — both are the
registry's. The registry moves the area as stored and every member of the community whose
area is the old key, of any status, in one transaction; that is why a template sync uses
it for an area whose substation the registry holds under another key, where a PUT under
the new key is refused (one area per boundary) and the old key's DELETE is refused while
members hold it.
Anything but 200 raises under REQ-0127: invalid_area_key (422, the new key is not an
area key), area_not_found (404), area_key_taken (409, the new key exists, the old key
included), community_not_found (404); code is None for a validation 422, a missing
grant (403) or a body that is not JSON. An unreadable 200 raises too. It needs
rec-registry.community.write.
Member fields and delivery points
Since rec-registry 1.7.0 each of a member's name, role and area has its own route and its
own action (rec-registry REQ-0081–REQ-0083), so a caller can be granted one field without
the general member PATCH. A delivery point can be corrected in one write that also relinks
the member's meters (?replaces=, rec-registry REQ-0084), and one active member at a time
holds a point (delivery_point_held, rec-registry REQ-0085). Onboarding uses them when an
operator corrects what a member declared; a refusal there is shown to the operator, so these
follow the raising helpers above.
REQ-0134 — a per-field member write sends one key to its own route
RecRegistryAdminClient.put_member_name, put_member_role and put_member_area send
PUT /admin/communities/{community_key}/members/{member_key}/name, /role and /area
respectively, with a body of only {"name": …}, {"role": …} or {"area": …}. None of
them has a parameter for any other member field, and none falls back to the general member
PATCH or to the profile route. Each answers the updated member as MemberDetailSchema, and
anything but 200 raises under REQ-0127 (invalid_role and unknown_area are 422; a
missing member or community is 404; code is None for a validation 422 or a missing
grant). Values are passed as given; the role and area sets are the registry's.
REQ-0135 — a delivery-point write sends replaces only when given, and a refusal raises with the code
RecRegistryAdminClient.put_delivery_point sends
PUT /admin/communities/{community_key}/members/{member_key}/delivery-points/{point_id} with
the body as given, and adds the query replaces=<old> only when the caller passed
replaces; without it the request carries no query. delete_delivery_point sends DELETE
on the same path. Both answer the member's delivery points after the write as
DeliveryPointsResponseSchema, and anything but 200 raises under REQ-0127:
delivery_point_held (409, another active member holds the point), delivery_point_linked
(409, on delete, a meter of the member names the point), member_not_found or
community_not_found (404), and a 404 with no code when replaces names a point the
member does not have. A 404 on delete is not read as "already removed".
The undecoded upsert_delivery_point keeps its contract — it returns the response, 409
included — and gains the same keyword-only replaces, defaulting to None, which sends no
query: an existing call sends exactly what it sent before.
REQ-0136 — the duplicate delivery points read answers the whole list, or raises
RecRegistryAdminClient.list_duplicate_delivery_points sends
GET /admin/communities/{community_key}/delivery-points/duplicates and answers the generated
DeliveryPointDuplicatesSchema — community_key and items, each item with
delivery_point (the registry's compared form, trimmed and lower-cased), holders
(member_key and the stored id, this community only), held_elsewhere and
active_holders — as the registry answers it (rec-registry REQ-0087). The route is not
paginated, so the wrapper sends one request and adds no paging; it does not re-sort, filter
or normalise. An empty items is an answer. Anything but 200 raises under REQ-0127,
including the uncoded 404 for a community that does not exist, so a missing community
is never read as one with no duplicates. It needs rec-registry.read.
Self-service reads
REQ-0132 — a refused self-service read raises RecRegistryApiError with the code
Since 1.6.0 the registry declares the 403 its self-service routes answer a caller who is
no member — {"detail": "You are not a member of any community", "code": "not_a_member"}
on /user/community, /user/member, /user/assets, /user/assets/{asset_key} and
/user/delivery-points (rec-registry REQ-0073).
Each of RecRegistryUserClient.get_my_community, get_my_member, get_my_assets,
get_my_asset and get_my_delivery_points answers its schema on 200 and on anything else
raises RecRegistryApiError under REQ-0127: status_code, the raw body, and the
registry's code and detail read from the top level of the body — so a caller that tells
"no member" apart does it with exc.status_code == 403 and exc.code == "not_a_member", and
a member owning nothing stays a 200 with no items. The response is read by the wrapper,
never by the generated parse: that parse calls response.json() on every status the route
declares, so a body that is not JSON on such a status (a proxy's page on the 403, or on
/user/assets/{asset_key}'s 422) would fail there with a decode error. It raises
RecRegistryApiError with code and detail of None instead. An undeclared status raises
the same exception, never the generated UnexpectedStatus, and an unreadable 200 raises
rather than answering None.
This replaces the earlier contract, under which these reads raised the generated
UnexpectedStatus. The consumers in the workspace other than dataset-api catch any
exception around these reads. dataset-api's row filter, which answers no rows on
not_a_member and nothing else, calls get_my_assets and reads this exception's code.
get_me is unaffected: the registry answers it 200 with no membership.