# SPAR Kundtestmiljön Reference

Practical reference for integrating with SPAR's customer test environment
(Kundtestmiljön): authentication, Personsök, and Filtransport avisering.
Compiled from SPAR's own documentation and verified against real API calls
where noted.

## Scope

Sparex covers two SPAR services:

1. **Personsök** (`personsok`) — SOAP search for a person by id number or
   name/address.
2. **Filtransport avisering** (`filtransport`) — REST file upload/download to
   get notification ("avisering") updates for id numbers already tracked.

Everything else SPAR exposes (urval, personnummersättning, Kundapplikationen)
is out of scope.

## Authentication

All SPAR endpoints require **mutual TLS** with a client certificate issued by
Expisoft (`ExpiTrust Test CA v8` in test). SPAR does not accept certs from any
other CA. SPAR's own docs call this a "klientcertifikat (även kallat
organisationscertifikat)" — client certificate and organization certificate
are the same artifact, not two different things. (The "Stämpellegitimation"
cert type Expisoft also issues *is* a different thing — for digitally
sealing documents, not for API auth — and isn't needed for Sparex.)

- TLS 1.2 only; cipher suites: `TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256`,
  `TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384`, `TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256`.
- Certs are `.p12` (PKCS#12), password-protected.

### The full process — two manual steps, then the library

SPAR's own "Autentisering" page describes three steps. Only the third is
something `Sparex` (or any code) touches — the first two are one-time,
outside-the-library, human/administrative tasks for whoever is setting up a
cert (production or otherwise):

1. **Order the cert from Expisoft** ("Serverlegitimation/
   Organisationslegitimation"). Produces a `.p12` containing a key pair — a
   private key and its corresponding public certificate, bundled together.
   Normally valid ~2 years.
2. **Register the public half with SPAR, in advance.** Extract *just* the
   public certificate (not the private key — note `-nokeys` here, the
   opposite of the cert/key-splitting commands used elsewhere in this doc)
   and send it to SPAR's kundtjänst to link to the kundanvändare (customer
   user account):

   ```sh
   openssl pkcs12 -in Bolag_B.p12 -clcerts -nokeys -out Bolag_B_publik.pem
   ```

   This isn't optional, and nothing *creates* a new key here — the public key
   already exists inside the `.p12`; this just exports the existing public
   half so it can be sent somewhere. SPAR doesn't trust "any Expisoft-signed
   cert" — it specifically trusts whichever public keys have been registered
   against an account. A validly CA-signed cert that hasn't been registered
   this way won't authenticate.
3. **Connect** — present the full certificate (private key + public cert)
   via mTLS on every request. **This is the only step `Sparex` performs.**
   The TLS handshake itself does the cryptographic proof: SPAR's server
   verifies the connecting party actually holds the private key matching a
   public key it already has on file, without the private key ever being
   transmitted. That single handshake simultaneously answers "is this a
   legitimate Expisoft-issued party" and "which specific account is this" —
   no separate login call.

Sparex accepts the converted PEM material either as `certfile` and `keyfile`,
or directly as `cert_pem` and `key_pem`. The latter is intended for secrets
provided in memory (for example, environment variables); the library decodes
them for OTP TLS and does not create temporary credential files.

**Why the Bolag A test certificate authenticates without step 2 having been
performed here:** the public-key registration was already done as part of
SPAR's shared Kundtestmiljön sandbox setup — only step 3 is exercised by
Sparex's own testing. **The same shortcut will not apply to a production
certificate** — step 2 has to be completed (and confirmed by SPAR) before a
newly-issued production cert will authenticate at all, regardless of whether
the client code is correct. Easy to overlook, since nothing in the library or
its error output indicates this is the cause — a certificate that's valid
and CA-signed but unregistered will most likely simply fail to authenticate,
rather than produce a clearly diagnostic error.

### Getting a test certificate

1. SPAR publishes a batch of test identities ("Testcertifikat server- och
   stämpellegitimationer") with PIN letters, one per org. Each PIN letter has
   a **Unique identifier** (e.g. `EA88-9EAF-0A3B-6C78-S1A1`) and a
   **PIN/password**.
2. Go to `https://eid.expisoft.se` → "Ladda ner" (Download), enter the unique
   identifier, then the PIN, to download the `.p12`.
3. Store it locally — **never commit it**. Sparex's own testing keeps it in
   `tmp/certs/` (gitignored by the standard Mix `.gitignore`).

The **Bolag A** identity is used for Sparex's own testing:

| Field | Value |
|---|---|
| CN / Org | Bolag A |
| Org.nr | 165560000167 |
| Valid | 2025-01-08 – 2027-01-08 |
| Cert file | `tmp/certs/bolag-a.p12` (not committed) |
| PIN letter | `tmp/certs/bolag-a-pin-letter.pdf` (not committed) |
| Unique identifier | `EA88-9EAF-0A3B-6C78-S1A1` |
| PIN/password | `6323251803413456` |

The unique identifier and PIN above are SPAR/Expisoft's published test
credentials (from the public "Testcertifikat server- och
stämpellegitimationer" PIN letter) — not a secret, safe to keep here. They're
only good for re-downloading the `.p12` from `eid.expisoft.se`, and only
authenticate as the test identity "Bolag A" against SPAR's *test*
environment.

Other valid test identities are available if a second certificate is needed
(Bolag D, Kommun A/B, Testbolag 1/2/3), plus deliberately-broken ones for
negative testing: Bolag B and Kommun C are revoked ("SPÄRRAT"), Bolag C is
expired, Bolag E isn't valid yet.

### curl example (mTLS only, no request body)

```sh
curl --cert-type P12 --cert "tmp/certs/bolag-a.p12:6323251803413456" \
  "https://test-personsok.statenspersonadressregister.se/2023.1?wsdl"
```

## Test customer identity

SPAR's docs point to one shared customer/user for Kundtestmiljön:

| Term | Value | Lives on |
|---|---|---|
| `KundNrLeveransMottagare` | `500243` | `Sparex.Client` (tied to the cert — constant across every request) |
| `KundNrSlutkund` | `500243` | `Sparex.Uppdrag` (the end customer a request is *for* — varies per tenant in a multi-tenant app) |
| `SlutAnvandarId` | freeform, logged by SPAR for audit — e.g. `foretag-YYMMDD` | `Sparex.Uppdrag` |

Here all three values are `500243` because the test certificate, test
customer, and test calls all belong to the same party. They don't have to be
— `Client` only has to carry what's actually tied to the certificate; a SaaS
app serving several SPAR customers through one certificate builds one
`Client` and a different `Uppdrag` per tenant (see `Sparex.Client`'s and
`Sparex.Uppdrag`'s moduledocs).

**`UppdragId` is per-service, not shared:**

| Service | UppdragId |
|---|---|
| Personsök | `637` |
| Filtransport avisering | `1070` (confirmed working; `1089`, `1090`, `1092`, `1094`, `1095` also seen as avisering jobs from other testers sharing this sandbox) |
| Filtransport personnummersättning | `1091`, `1096` (seen in `/utfil` listings, unconfirmed) |
| Filtransport urval | `1093` (seen, unconfirmed; out of scope anyway) |

Using the wrong `UppdragId` for a service does not silently fail — SPAR
returns a clear `Client.EjFilHant` error on Filtransport (see below), making
the failure mode easy to diagnose. `GET /v3/utfil` lists every result file
visible to `KundNr 500243` across all testers using the sandbox, including
their `uppdragId`s — useful for finding a working id for an undocumented
service.

## Personsök (SOAP)

- Test endpoint: `https://test-personsok.statenspersonadressregister.se/2023.1`
- WSDL: same URL + `?wsdl`
- Prod endpoint: `https://personsok.statenspersonadressregister.se/2023.1`
- SOAP 1.1, document/literal, one operation `PersonSok`
- `SOAPAction`: `"http://skatteverket.se/spar/personsok/2023.1/PersonsokService/Personsok"`
- `Content-Type: text/xml; charset=utf-8`

Request body wraps `SPARPersonsokningFraga` with `Identifieringsinformation`
(KundNr/UppdragId/SlutAnvandarId) and either an `IdNummer` lookup or
name/address search args. Response is `SPARPersonsokningSvar`: zero or more
`PersonsokningSvarspost` (an `AviseringPost` — same record shape used by
Filtransport avisering), or an `Undantag` fault, or
`OverstigerMaxAntalSvarsposter` if too many hits.

An **empty result is not an error** — no `Undantag`, just zero svarsposts.
Don't mistake a valid empty response for bad credentials: the generic
Skatteverket test-personnummer list (skatteverket.entryscape.net) is not the
same as SPAR's own curated test dataset, so most of those numbers return
nothing even with correct `KundNr`/`UppdragId`.

### Known-good test personnummer

SPAR publishes a "Testfall för personsökning i kundtestmiljön" PDF (linked
under "Relaterade dokument" on the Personsök docs page) with ~40 categorized
test identities — sekretess, avregistrerad, samordningsnummer, relations,
utlandsadress, etc. `195704133106` ("Förnamn, mellannamn, efternamn..." test
case) is confirmed to return a full record: name, birthdate, two
folkbokföring history entries, and a Swedish address. Use that PDF as the
fixture source when writing tests — it's the authoritative list of what's
actually loaded in the test register, unlike generic Skatteverket
test-personnummer.

23 of the ~40 categories were fetched live; 10 are kept as fixtures under
`test/fixtures/personsok/` (`Sparex.PersonsokTestfallTest`), covering shapes
the one happy-path fixture didn't: sekretess (no `Namn` at all when
`Sekretessmarkering: "JA"`), skyddad folkbokföring, samordningsnummer/
immunitetsnummer `Typ` values, avregistrerad/avliden (`Persondetaljer`
becomes a list with `Avlidendatum`), hänvisning to a superseded id number,
utlandsadress, kontaktadress, and lägenhetsnummer.

One finding initially looked like a permissions gap but wasn't: searching
`relation_vardnadshavare` (201607052394) initially returned no `Relation`
data at all, which looked like the test customer lacking the "Relationer"
extended permission. **That was incorrect** — the actual cause was that the
request builder never sent `SlutAnvandarUtokadBehorighet` in the first place.
Once added (`utokad_behorighet: [:relationer]` option, see below), the same
test customer returned two `VÅRDNADSHAVARE` relations for that id number
without issue: **`KundNr 500243` already has the Relationer permission** in
Kundtestmiljön. A missing optional element silently producing an
empty-but-valid result can look identical to a missing permission. Fixtures
for both the without- and with-behörighet requests are kept
(`relation_vardnadshavare.xml` / `relation_vardnadshavare_with_behorighet.xml`)
to keep that distinction visible in tests.

### Name/address search

`Sparex.Personsok.search_by_name_address/4` searches by name and/or address/
geography instead of a direct id-number lookup (`PersonsokningFraga` without
`IdNummer` — the schema's other `xs:choice` branch in
`PersonsokningSokparametrar-1.2.xsd`). Params map to SPAR's search argument
elements (`Sokargument-1.3.xsd`): `:namn` *or* `:fornamn`/`:mellan_efternamn`
(mutually exclusive — `MellanEfternamnSokArgument` matches against either
`Mellannamn` or `Efternamn`, not just surname despite the name),
`:utdelningsadress`, `:postort`, `:post_nr` *or* `:post_nr_from`/
`:post_nr_tom`, `:fodelsedatum` *or* `:fodelsedatum_from`/`:fodelsedatum_tom`,
`:kon`, `:lan_kod`, `:kommun_kod`, `:distrikt_kod` *or*
`:distrikt_kod_from`/`:distrikt_kod_tom`, and `:fonetisk_sokning` (boolean —
phonetic/"sounds like" matching instead of exact spelling). All the exact-vs-
range pairs are validated client-side; mixing both sides of a pair raises
`ArgumentError` before any request is sent.

All 16 of SPAR's own name/address test cases (the "Testfall – personsökning
med namn och geografi" section of the test-case PDF) were run against
Kundtestmiljön and behaved exactly as documented, including the two edge
cases worth knowing about:

- **Too many matches returns `OverstigerMaxAntalSvarsposter`, not a (possibly
  huge) result list** — a wildcard search like `mellan_efternamn: "Me*"`
  scoped only by `lan_kod` matched 155 people against a cap of 100, and
  `search_by_name_address/4` surfaces that the same way
  `search_by_id_nummer/4` does: `{:error, {:too_many_results, %{"AntalPoster"
  => "155", "MaxAntalSvarsposter" => "100"}}}`.
- **No match is `{:ok, []}`**, not an error — same as id-number search.

Fixtures for 10 of the 16 cases are kept under
`test/fixtures/personsok/namesok/` (`Sparex.PersonsokNamesokTest`).

### Extended permissions (`SlutAnvandarUtokadBehorighet`) and friskola eligibility

Grundbehörighet (names, addresses, folkbokföring history — everything used so
far) is automatic. Five extended permissions exist on top of it: Relationer,
Medborgarskap, Taxering, Fodelsehemort, Sekretess. Whether
`uppdrag.slut_anvandar_id` actually *holds* one is statutory — Skatteverket
grants it per organization type via a formal decision — but *asking* for one
held is just a request field, implemented as
`Sparex.Personsok.search_by_id_nummer(client, uppdrag, id_nummer, utokad_behorighet: [:relationer])`.
Leaving it off doesn't error even when entitled to the data — SPAR just omits
it, so don't mistake "forgot to ask" for "not allowed."

**All five confirmed working against Kundtestmiljön** (`KundNr 500243` holds
all of them there) — each checked by fetching the same person with and
without the flag and diffing what comes back (`Sparex.PersonsokTestfallTest`):

| `utokad_behorighet` | What appears that otherwise doesn't |
|---|---|
| `:relationer` | `Relation` list (e.g. `VÅRDNADSHAVARE` — guardians) |
| `:medborgarskap` | `Persondetaljer["SvenskMedborgare"]` |
| `:taxering` | `InkomstAr` (income tax year) at the top level |
| `:fodelsehemort` | `Persondetaljer["Fodelseforsamling"]` / `["FodelselanKod"]` |
| `:sekretess` | For a secrecy-marked person, their actual `Persondetaljer` (birthdate etc.) — without it, only the secrecy markers themselves come back |

`:taxering` is worth a caveat: requesting it only surfaced `InkomstAr` for
the person tested, not `SummeradInkomst` or `Fastighet` — even though that
same person's `Fastighet` data is visible in the Filtransport avisering
fixture (which isn't gated per-request the way Personsök is). Don't assume
`:taxering` via Personsök returns the full taxering picture Filtransport
avisering does; it may need a different/additional authorization, or just
depend on what data exists for that person. Untested against any id number
that actually has `SummeradInkomst` populated.

**Relationer** (spouse/partner/guardian data, including `vårdnadshavare`/
guardian relationships) is restricted to a specific enumerated list of
organization types:

> Myndigheter, Banker, Kreditmarknadsföretag, Försäkringsföretag,
> Tjänstepensionsföretag, Fondbolag, AIF-förvaltare, Värdepappersbolag,
> Betalningsinstitut, Institut för elektroniska pengar,
> Kreditupplysningsföretag, Pensionsstiftelser, Inrättningar för
> detaljhandel med läkemedel som bedrivs med tillstånd enligt 2 kap. 1 §
> lagen (2009:366) om handel med läkemedel, **Enskilda huvudmän enligt 2
> kap. 5 § skollagen (2010:800)**, Privata vårdgivare enligt 1 kap. 3 §
> patientdatalagen (2008:355), Företag som på uppdrag av försäkringsföretag,
> tjänstepensionsföretag eller pensionsstiftelser administrerar
> försäkringar, pensioner eller liknande utfästelser, Föreningar och
> stiftelser (om uppgifterna behövs i samband med släktforskning)

**Example — an independent school (friskola):** this matches "Enskilda
huvudmän enligt 2 kap. 5 § skollagen (2010:800)" directly (the huvudman/legal
operator of an independent school, not the school building itself). Municipal
schools aren't named specifically but would go through "Myndigheter" instead,
since kommuner are government authorities.

To actually get this permission:

1. Fill out **SKV 1537** ("Ansökan Ändringsavisering, Nulägesavisering,
   Personnummersättning och Personsökning") — the application form for
   Personsök/avisering access, which is also where extended permissions are
   requested.
2. Submit to `spar@skatteverket.se` (or by mail).
3. **Skatteverket decides**, not SPAR customer service — normal processing
   time is ~2 weeks from a complete application. "Vilken behörighet du har
   framgår av myndighetsbeslutet" — SPAR enforces exactly whatever the
   decision grants.
4. Once granted, pass `utokad_behorighet: [:relationer]` to
   `search_by_id_nummer/4` — implemented and verified working against
   Kundtestmiljön (see above).

## Filtransport (REST + JSON, over mTLS)

- Test base: `https://test-filtransport.statenspersonadressregister.se/v3`
- Prod base: `https://filtransport.statenspersonadressregister.se/v3`
- **Authoritative source: `docs/filtransport-v3.yaml`** — SPAR's own OpenAPI
  spec for this API. Everything below was cross-checked against it and
  against real Kundtestmiljön calls; where they disagree, that's called out
  explicitly rather than silently trusting one over the other.

| Endpoint | Method | Purpose |
|---|---|---|
| `/infil/lamna/{kundNr}/{uppdragId}` | POST | Upload an input file (multipart, field name `file`) |
| `/infil` | GET | List uploaded input files + status, optional `?filstatus=` |
| `/infil/{filnamn}` | GET | Status of one input file |
| `/infil/avvikelse/{filnamn}` | GET | Discrepancy/validation details |
| `/utfil` | GET | List available result files, optional `?filstatus=` (shared across all testers on this KundNr) |
| `/utfil/{filnamn}` | GET | Details of one result file |
| `/utfil/hamta/{filnamn}` | GET | Download result file (zip containing one XML) |
| `/version/{uppdragId}` | GET | Current + valid XML schema versions for an uppdrag |
| `/version/{uppdragId}` | PUT | Change which schema version an uppdrag uses |

`list_infiler/2` and `list_utfiler/2` take `filstatus: :atom` to filter
server-side (`:alla` (default/omit), `:mottagen`, `:validerad`, `:inkorrekt`
for infiler; `:alla`, `:ny`, `:hamtad` for utfiler) — confirmed working
against Kundtestmiljön. `version/2` and `update_version/3` take a
`Sparex.Uppdrag` but only use `uppdrag_id`; calling `version/2` with an
uppdrag_id scoped to the wrong service (e.g. Personsök's `637`) correctly
fails with `Client.EjUppdrag`/"Ej behörig för uppdrag" rather than silently
succeeding.

The OpenAPI spec's documented status enums are narrower than what has
actually been observed in practice: `infil.status` per spec is `MOTTAGEN |
VALIDERAD | INKORREKT | BORTTAGEN`, but only `VALIDERAD` has been observed.
`utfil.status` per spec is only `NY | HAMTAD`, but `BORTTAGEN` has also been
observed (consistent with the ~24h retention policy mentioned elsewhere in
SPAR's docs) — the spec doesn't document that value, so don't assume
`filstatus` filtering can select for it.

### Upload example

```sh
curl --cert-type P12 --cert "tmp/certs/bolag-a.p12:6323251803413456" \
  -F "file=@infil.xml;type=text/xml" \
  "https://test-filtransport.statenspersonadressregister.se/v3/infil/lamna/500243/1070"
```

Response: `{"meddelande":"Fil uppladdad","lamnadFil":"...","sparadSom":"...","uuid":"..."}`

### Infil XML shape (ändringsavisering / nulägesavisering by id number)

```xml
<?xml version="1.0" encoding="UTF-8"?>
<SPARInfilIdNummer xmlns="http://statenspersonadressregister.se/schema/infilidnummer/infilidnummer-2023.1"
    xmlns:komp="http://statenspersonadressregister.se/schema/infilidnummer/infilidnummerkomponenter-2023.1">
  <komp:InfilIdNummer nulage="JA">195704133106</komp:InfilIdNummer>
</SPARInfilIdNummer>
```

`nulage="JA"` asks for a current-status (nuläges) record rather than a
change-only (ändrings) one. A CSV form is also accepted (`personnummer;J`),
one id per line; this has not been tested — XML is the verified format.

### Download + result shape

```sh
curl --cert-type P12 --cert "tmp/certs/bolag-a.p12:6323251803413456" \
  -o result.zip \
  "https://test-filtransport.statenspersonadressregister.se/v3/utfil/hamta/{filnamn}"
```

Downloads a zip containing one XML file, root element `SPARAvisering`
(`Avisering.xsd`): `IdentifieringsinformationFil` (Medieleverantör/
Mediemottagare/Filinformation) followed by `AviseringPoster` — same
`AviseringPost` record shape as Personsök's search results.

**The test environment does not actually process uploaded infiler into fresh
results.** Multiple infiler were uploaded and sat at `VALIDERAD` indefinitely
(30+ minutes, multiple attempts) with no corresponding result appearing in
`/utfil`. The explanation is on the Kundtestmiljön page, easy to miss:

> "Observera även att infilsresultaten gallras automatiskt en gång i timmen."
> (input file results are purged automatically once an hour)
>
> "För att simulera nya filer kommer status på alla hämtade utfiler sättas
> till 'Ny' var 15:e minut." (to *simulate* new files, the status of all
> already-fetched output files is reset to "Ny" every 15 minutes)

So Kundtestmiljön has a **fixed, canned result file per `UppdragId`** (for
avisering, `SPARAvisering_20230927192035_SPAR500243-1070.xml.zip`), and just
recycles its status (`HAMTAD` → `Ny` → `HAMTAD`...) every 15 minutes to allow
exercising polling/download logic — it never generates a result reflecting
what was actually uploaded. Uploading still validates the infil (so malformed
input is testable), but don't expect — or wait for — a tailored result. Use
the canned file to test the download/unzip/parse path; there's no way in this
sandbox to verify an uploaded id number round-trips into fresh avisering data.
Production is expected to behave differently (real processing); this is a
Kundtestmiljön-only limitation.

### Ändringsavisering vs Nulägesavisering: the actual product model

This distinction matters for a common integration pattern: an initial bulk
load of existing customers' addresses, followed by ongoing (daily/weekly)
pickup of only the people who changed. Confirmed from SPAR's service pages
(not the technical teknisk-info/API pages, which only describe file
mechanics — `vaara-tjaenster/aviseringstjaenster/` and
`vaara-tjaenster/prenumeration/`):

- **Nulägesavisering** (`nulage="JA"` per id number) — a full current
  snapshot regardless of whether anything changed. Available as a one-time
  pull ("engångsuttag") or repeated pulls. This covers an **initial sync**.
- **Ändringsavisering** ("Avisering med infil", `nulage="NEJ"`/default) — a
  **subscription** ("Prenumeration") that notifies only about people whose
  data actually *changed*. This covers an **ongoing sync**.
- Subscription cadence is a real, selectable setting: **daily** (a result
  file Tuesday–Saturday, 5×/week — skips Sun/Mon), **weekly**, **biweekly**,
  or **monthly** ("daglig, veckovis, varannan vecka och månatlig
  prenumeration").
- **Bruttoavisering** is a different, much bigger thing — gross/unfiltered
  notifications for a large share of the entire population, gated by a
  separate Skatteverket decision, only for recipients who "regularly handle
  data on a significant portion of the population." Not relevant here.

**Resolved, via a direct call with SPAR (2026-10-02):**

- The subscription's cadence (monthly/weekly/etc.) **and its specific run
  date** (e.g. "the 1st of every month") are configured manually, outside the
  API — on SPAR's own website/Kundapplikationen, not something
  `Sparex`/Filtransport can set or read. This is a one-time (or infrequent)
  account-setup step for a given `uppdrag_id`, not a per-sync library
  concern.
- It is **not** set-and-forget. Each cycle requires a fresh upload: the day
  *before* the configured run date, upload the current list of tracked
  personnummer via `upload_id_nummer/4`. On the run date, SPAR processes
  against that upload and produces an utfil containing only the people whose
  data actually changed.
- So for a monthly subscription configured to run on the 1st: upload on the
  last day of the preceding month, then poll/download starting on the 1st.
  Missing that upload window presumably means no result that cycle — safest
  to automate the upload itself, not rely on remembering it.

This means the "first sync vs. ongoing sync" distinction isn't really about
different SPAR services — it's the same Ändringsavisering `uppdrag_id`,
re-uploaded every cycle, just with a different `nulage` choice per id number:
`nulage="JA"` the first time a personnummer is added (get their current
address even though nothing's "changed" yet from SPAR's perspective),
`nulage="NEJ"`/default on every subsequent cycle for ids already tracked (get
them back only if they actually changed). Practically: keep re-submitting the
full customer id list every cycle, flipping `nulage` to `true` only for
newly-added customers that cycle.

This is squarely an **application-level scheduling concern**, not a gap in
the library — `upload_id_nummer/4`, `list_utfiler/2`, `utfil_status/2`, and
`download_utfil/2` already cover the mechanics needed; what the integrating
app needs is a scheduled job (e.g. Oban cron) that knows the configured run
date per `uppdrag_id` and uploads the day before.

## Schema source

All XSDs/WSDL: `https://xmls.statenspersonadressregister.se/se/spar/` — two
subtrees, `granssnitt/` (per-service request/response + WSDL) and `komponent/`
(shared building blocks: person, namn, adress, metadata, etc.). Human-readable
docs + version history: SPAR's teknisk-info pages, versioned (e.g. `20231`).
Current version used throughout this doc: **2023.1**.

## Known limitations and open questions

- Production avisering processing has not been directly observed (unlike the
  canned/recycling Kundtestmiljön behavior above) — the subscription/run-date
  model SPAR described implies real processing (there would be no reason to
  coordinate a specific run date otherwise), but this should be confirmed
  once real account access is available.
- The CSV infil format is unverified; only XML has been tested.
- `:taxering` via Personsök is only confirmed to surface `InkomstAr`, not
  `SummeradInkomst`/`Fastighet` — see the caveat under "Extended permissions"
  above. Worth retesting against a person with populated income/fastighet
  data once one is identified.
- Whether the Ändringsavisering subscription's configured run date can be
  *read* back via the API (e.g. via `version/2` or elsewhere) is unconfirmed
  — if possible, a sync scheduler could derive its timing from SPAR directly
  instead of duplicating the run date in application config.
