Import and export
The other way a community arrives: a YAML or JSON bundle, imported wholesale. This is how a community is seeded and how it is restored from a backup, and it is the only place in the service where a write may delete what it was not given.
The procedure and the file format are in import & export. What follows is what the behaviour must be.
REQ-0032 — an import is a full replace, and it is atomic
Importing a community deletes the existing community with every member and asset, then recreates the whole graph from the bundle. Entities missing from the new bundle are gone; changed ones are recreated; new ones are added.
The work is one transaction: if any step fails, nothing is applied. That is not a convenience — a half-applied replacement is a community whose members have been deleted and not recreated, and there is no state to roll back to except the transaction.
Rows are built by src/celine/rec_registry/services/members.py, the same code the admin
write API uses. Two implementations of "what a member row looks like" would drift on the
first schema change; REQ-0037 is what would notice.
REQ-0033 — overwriting an existing community must be asked for
An import naming a community that already exists is refused unless force is set:
409 over HTTP, and a non-zero exit from the CLI, in both cases naming the members and
assets that would have been deleted.
With force, the replacement proceeds and the loss is accepted. Creating a community that
does not exist yet needs no force.
This guard exists because the premise changed. Full replacement was safe while a YAML file was the only source of members; now that members arrive at runtime through the write API, restoring a stale export is the likeliest way to lose weeks of approvals. The counts in the refusal are the point — they are how a caller judges whether forcing is warranted.
REQ-0034 — a dry run reports and never writes, and is never blocked by the guard
dry_run returns the same report — what would be deleted, what would be inserted, with
what warnings — and performs no database work at all: nothing is added and nothing is
deleted.
A dry run against an existing community reports instead of refusing, even without
force. Seeing the counts is exactly how a caller decides whether forcing is warranted,
so the guard must not block the request that informs it.
It also lists, in the report's refusals, every sensor_held refusal the import would make
(REQ-0069), every invalid_role, invalid_status and unknown_area refusal (REQ-0066,
REQ-0074), and every invalid_area_boundary refusal (REQ-0067).
REQ-0035 — a meter with no sensor id is skipped, with a warning naming it
The import continues and the report carries a warning naming the asset key and the missing field. Everything else in the bundle is applied.
A meter is identified by its sensor_id throughout the platform — it is how a reading
finds its owner (REQ-0039). One stored without it is unreachable rather than merely
incomplete, so it is not stored; and one bad meter is not a reason to refuse a community
of two hundred members, so the import is not failed either.
A sensor_id that is blank after trimming counts as missing and is skipped the same way
(REQ-0069).
REQ-0036 — the report names what was deleted, what was inserted, and what was warned about
Every import answers an ImportReport carrying the community key, deleted and
inserted counts by entity type, the list of warnings, and — filled by a dry run only — the
refusals the import would make, each {code, detail} (REQ-0069). The YAML route answers a
MultiImportReport, one entry per document.
A malformed request — no bundle field, or a body that is not JSON — is 422 before any
of that.
The counts are what makes a destructive operation reviewable after the fact. deleted
being non-zero is the caller's evidence that a replacement rather than a creation
happened.
REQ-0037 — a community exports the same whether its members arrived by API or by bundle
Create a member through the write API, export the community, re-import the export: the member is still there, unchanged, with its delivery points and its assets.
This is the property the two write paths exist to preserve, and the one that is invisible from reading either path alone. If it breaks, the symptom is a community that exports differently depending on how its members arrived — which nobody notices until a restore produces something subtly unlike the original, and by then the original is gone.
Practically: the file is a seed, GET /admin/export is a backup, and once members
arrive at runtime the database is the source of truth.
One field is knowingly excluded from this guarantee: the declared schema version does not survive the round trip (REQ-0018).
Areas keep the guarantee too: a v0.7 community whose areas arrived by bundle and by the area
PUT exports them with their boundary and topology and re-imports unchanged, with no
warning. An export taken before schema v0.7 has areas without a boundary, which REQ-0074
refuses, so it has to be reshaped before it restores.
Planned: the source of truth becomes per collection — areas come from onboarding templates (ADR-0006), members through onboarding, meters from a manager — and the database holds all three.
REQ-0074 — an import that breaks an invariant is refused whole, before any database work
A bundle is refused, and nothing is deleted or inserted, when:
- two of its active members hold the same trimmed sensor id, or one of them holds a sensor an active member of another community holds (REQ-0069) — the replaced community's own current rows do not count, since the import deletes them;
- an asset key is longer than the 128 characters
asset.keyholds —asset_key_too_long(REQ-0028), implemented with the sensor clause; - two of its active members hold the same delivery point, or one of them holds a point an
active member of another community holds —
delivery_point_held(REQ-0085), as the sensor clause, reported by member key and never by the point's id; an active member's meterpodthat is not one of its own points holds that id the same way (REQ-0093); - a member's
roleorstatusis outside its set, or itsareais not one of the bundle's areas (REQ-0066); - an area's key is not an area key —
invalid_area_key(REQ-0067), one refusal per key; - an area breaks the one-substation rule —
invalid_area_boundary(REQ-0067), one refusal per area and one per boundary id two areas share, judged against the bundle's owncommunity.topology.
The refusal is 422 over HTTP and a non-zero exit from the CLI, with the code of the invariant
(REQ-0073) and a report naming the offending member, asset and area keys of this bundle; a
sensor held in another community is reported without naming that community or member. The
check runs after parsing (REQ-0019), and a dry run answers the report with every refusal it
would make rather than stopping at the first (REQ-0034).
The schema-version check stays the warning REQ-0018 describes; this refuses on content, whatever version is declared. A bundle written before schema v0.7 that has areas is refused, and there is no compatibility branch: a file is reshaped outside the product. The runtime writes already refuse these rows, and an import that only warned would be the way to create them anyway. Decided in ADR-0007.
Each clause has this refusal's shape: the sensor clause as REQ-0069 describes, the asset-key
length with it, the role, status and area clause as REQ-0066 describes — each out-of-set field
one refusal, member '<key>': role 'x' is not one of …, the area judged against the bundle's
own community.areas — and the area-key and area-boundary clauses as REQ-0067 describes. A
refused forced re-import leaves the existing community as it was. The real import's code is
the first refusal's, in the order member values, area keys, area boundaries, asset keys,
sensors, delivery points.
REQ-0075 — a community is retired by a forced import naming it with no members
A forced import of a bundle that names an existing community and carries no members deletes
every member and asset of that community and keeps the community, with the metadata the bundle
carries. The report counts what was deleted, as REQ-0036 says. Every sensor the old members
held is released, so a sensor can be attached in another community afterwards without
sensor_held. Without force it is refused like any overwrite (REQ-0033) and nothing is
deleted. "Keeps the community" is by key: the import replaces the graph (REQ-0032), so the
community's row is written anew under the same key and answers a new id.
There is no community delete route and no retired status: retirement is this import, performed by an operator holding the import grant — the grant that is purge-equivalent for a whole community (REQ-0006). On a deployed realm a bundle carries a community's administrative data only, and members arrive through onboarding; members loaded from a file are for local development. Decided in ADR-0009.