Skip to content

Interface semantics

Published verbatim from Gungner’s Brayns partner interface pack, version 1.6.0 (semantics.md). This page is not edited here.

This file states what the generated reference cannot: the rules behind the routes. Exact shapes, names and codes are in reference.md, mcp-tools.md, events.md and errors.md, generated from the code and authoritative where they differ from anything written here. Interface version and change history: VERSION, CHANGELOG.md.

Gungner returns verified identity and registry data with evidence. Brayns decides what the data means — risk, cases, review, escalation. Brayns’s agents act under the customer’s delegation: the customer (the tenant) holds the contract, the legal basis and the permits the data requires — in Sweden the SPAR permit (§8), elsewhere as §13 states; Brayns holds consumer credentials that are valid for that one tenant and nothing else.

  • A tenant grants Brayns a consumer with a set of scopes. Brayns obtains a token with the OAuth2 client-credentials grant (/oauth/token). The token is bound to exactly one tenant; there is no cross-tenant token and no “Brayns-wide” token.
  • Scope set (SPEC-002, extended by ADR-014 and SPEC-004b): identity:read, kyb:read, kyc:read, monitoring:read, monitoring:subscribe, decisions:write, events:read, screening:read, screening:run, kyb:refresh. (screening:policy also exists in the same enum but is not a consumer scope — §Scope of the spec that added it — so it is not listed here.) reference.md shows which routes each scope opens and which scopes are live in the current version. kyc:read became live in v1.2.0 with the SPAR person profile (§8); a scope is necessary but never sufficient there, because the tenant’s SPAR permit gates it as well. monitoring:read arrived in v1.3.0 as the read half of monitoring:subscribe: it opens the subscription reads and nothing else, so a consumer that should only ever see the watch list no longer has to be granted the authority to change it. screening:read/screening:run arrived in v1.4.0 with screening (§9); screening:run is a write scope even though the route it guards is read-shaped, because running a check writes an append-only row and emits an event. The subscription reads accept monitoring:read in addition to the scopes that already opened them, so nothing a consumer could call before stops working. kyb:refresh (v1.6.0) opens POST /v1/companies/{orgnr}/refresh and the refresh_company tool, and nothing else. Until 1.6.0 kyb:read opened that route; it no longer does — a refresh is a paid action a tenant may want to withhold from a consumer that only reads, so the scope that pays is separate from the scope that reads. This is one of the four changes 1.6.0 ships under the §11 exception; a consumer that refreshed under kyb:read receives 403 forbidden from 1.6.0 until its grant carries kyb:refresh.
  • A grant carries, besides its scopes, an explicit country set (v1.6.0, §13) — never empty, always within the countries the tenant is provisioned for. Grants Gungner provisions on paper terms carry an expiry; grants a tenant creates in the portal may. A token carries the grant’s scopes, countries and id; what the token says is a copy, the grant record governs. No token outlives its grant, and an expired grant behaves as a revoked one.
  • Every access is written to the tenant’s audit log with the consumer id, and to the grant’s own access log; which requests that log records, and which it does not, is stated in reference.md. The grant’s owner — the tenant for grants it created in the portal, Gungner ops for grants provisioned on paper terms — can revoke or narrow it at any time, scopes and countries alike; the change is immediate, not at token expiry. Widening beyond what a token was minted with takes effect when the next token is minted; a scope or country the token already carries, removed and restored within its lifetime, is usable again at once — never more than was issued.
  • Data delivered under tenant A’s delegation is tenant A’s. Brayns must not reuse it in tenant B’s cases, even for the same person or company (penningtvättslagen 5 kap. 8 § forbids cross-matching between obliged entities; Gungner keeps no cross-tenant profile store and expects the same of consumers).
  • A subject id is an opaque ULID, tenant-scoped, never derived from an identifier, issued by Gungner at first verification or resolve. The same person has different subject ids at different tenants.
  • Aliases (ADR-007). After a merge, the older id remains a valid alias that resolves one hop to the canonical id. Every response carries the canonical id and its aliases. Brayns stores the canonical id and treats any alias as equal; it never assumes an id it has stored is still canonical without reading the aliases.
  • Reference ids (Brayns’s or the customer’s own ids) are many-to-one onto a subject.
  • Identifiers carry a country (v1.6.0, ADR-017). An identifier is typed by country, scheme and value. In a request, a bare identifier is Swedish — <orgnr> and se:<orgnr> are the same — and every other country is written with its prefix: lt:<code> for an įmonės kodas or an asmens kodas. Responses carry the country explicitly and never rely on the prefix; reference.md shows the shape and the schemes. A subject may hold identifiers in more than one country and is still one subject with one id; what a grant sees of it is bounded by the grant’s countries (§13).
  • Identifier changes (samordningsnummer → personnummer, org.nr changes on restructuring) keep the subject; the identifier history is Gungner’s, not exposed as a list in v1.
  • Brayns’s commitment: the Gungner subject id is carried on customer records and transaction-monitoring events so identity, KYB and transaction data join on it across Finshark and Brayns.
  • Every data read returns {stale: boolean, data}. stale: true means the data was served from Gungner’s cache past its revalidation window because the upstream was unavailable or the tenant’s daily paid budget was exhausted. Brayns decides per use whether stale is acceptable; where it is not (onboarding decisions), Brayns calls the refresh route (kyb:refresh, §2), which is a paid action under the tenant’s budget.
  • Data is Roaring-shaped. Field sets marked verified in reference.md come from a verified upstream contract; fields from a provisional contract can change shape when verification lands (additive where possible, announced in CHANGELOG.md). In v1: Bolagsverket free tier verified; Bolagsverket paid tier provisional; signatory rules are text passthrough (a structured form is a later, additive change).

Every field group carries: source, retrieved_at, call_ref (Gungner’s record of the upstream call), upstream_ref (only when the upstream supplied one), digest (hash of the raw upstream response), schema_version. transaction_ref is a deprecated alias of call_ref in v1 and disappears in v2. To dispute a value, quote the call_ref; Gungner can re-fetch and compare digests without holding raw personal data.

  • The event log is cursor-based, ordered per tenant, at-least-once. Every event has an id and an idempotency key; Brayns dedupes on the event id.
  • A malformed cursor returns 400; cursors do not expire, and they stay valid across a purge (§7). There are no sequence numbers; Brayns restarts from its last good cursor or from a time filter. A well-formed cursor beyond the newest event the grant can see returns an empty page and the same cursor back.
  • Current event types are in events.md. Live since SPEC-005 (monitoring, ADR-011): company.changed (field diff with values), company.status.changed, signatory.changed, ubo.changed, refresh.completed, refresh.deferred, subscription.* lifecycle events. person.changed is declared but not yet emitted — its shape is settled (paths and new values; the marker alone under identity protection) and so is the permit that gates it, but the scheduled person source that would produce one is not built. See §7. Every change event carries the subscription id, the canonical subject id with aliases, evidence, and trigger (on_demand when your own refresh call caused it, scheduled when the cadence did). GET /v1/events?types= filters the same stream by type — cursors are unaffected, so a filtered page can come back short while next_cursor advances. Signed webhooks (Svix) follow in a later version; the log is the delivery surface in v1.
  • Every event carries country (v1.6.0). A change event is filed under the country of the source that produced it: a watch runs in the country its connector serves, so a Swedish company’s change is a Swedish event even when the subject also holds a Lithuanian identifier. A merge event is filed under the canonical subject’s country, or the tenant’s own when the subject holds none. The stream a grant reads is bounded by the grant’s countries the way types= bounds it: a page can come back short while next_cursor advances, and a cursor stays valid across grants of the same tenant.

A subject is monitored only while the tenant holds an active subscription carrying a legal basis (aml_cdd, contract, legal_obligation, consent, legitimate_interest).

Withdrawing a country from the tenant suspends every subscription in that country with subscription.suspended reason: country_deprovisioned; restoring the country resumes those that no other condition still suspends.

A subscription carries the country of the identifier it watches. Where that country has no scheduled source yet, POST /v1/subscriptions answers 501 country_not_supported (§10, §13) and creates nothing; under a grant that carries Sweden, a subject that also holds a Swedish identifier is watched as Swedish.

Person subscriptions additionally require the tenant’s active SPAR permit, and are not yet a change feed. Creating one works, the permit gates it exactly as it gates a person read (§8), and a permit that is withdrawn or lapses suspends the subscription automatically with a subscription.suspended event carrying reason: permit_lapsed. What such a subscription does not yet do is emit person.changed: the scheduled person source is not built. For now a person subscription tracks the permit, not the person — do not build a change feed on it, and read GET /v1/persons/{ref} when you need the current state.

Suspension pauses events; ending stops all upstream work for that subject immediately; tenant-private data is purged after the tenant’s retention period (default 90 days), which replaces each purged event’s payload with {purged: true} and keeps its envelope, so a cursor stays valid across a purge. Change events exist only while a subscription is active — a refresh call for a subject with no subscription, or with a suspended or ended one, returns the data and emits nothing. Brayns owns re-rating, cadence per risk class and cases; where Gungner runs the eID, Brayns will call request_reverification (later version); where the customer runs its own BankID, Brayns instructs the customer.

GET /v1/persons/{ref} and the get_person_profile tool return the SPAR person profile. ref is an identifier_ref — an opaque id, never a personnummer — obtained from POST /v1/subjects/resolve (which returns it) or from GET /v1/subjects/{id}. Scope: kyc:read, distinct from kyb:read, because this is person data under a different legal basis rather than company-linked data about a person.

  • Starting from an identifier: lookup_person (v1.3.0). ADR-012 §10 recorded that a kyc:read-only consumer on MCP had no tool that returned a ref — get_subject is kyb:read — and left the fix to the spec that wanted it. lookup_person is that fix: it resolves a personnummer or samordningsnummer and returns the profile in one call, the person mirror of lookup_company, under kyc:read alone. It takes the identifier’s scheme explicitly, as POST /v1/subjects/resolve does; reference.md names the field and the schemes. Everything in this section governs it unchanged — the permit gate, the protection rule, and never-stale — because it is those two routes and nothing else.

  • The permit is the tenant’s, not Gungner’s. Gungner calls SPAR as ombud under one organisationscertifikat, on behalf of a named end customer, and the legal basis is that customer’s own SPAR permit. A tenant without an active permit — missing, not yet valid, expired — receives 403 spar_permit_required; a withdrawn one gives 403 spar_permit_suspended. In both cases no upstream call is made and no cached profile is served: the gate is evaluated before Gungner’s own cache is read, so a lapsed permit stops person data the tenant already obtained, not merely new lookups. The same permit and the same code gate POST /v1/subscriptions with kind: person (§7) — one permit, one answer, whether the tenant is reading a person now or asking to be told when one changes.

  • Under a protection marker, no address is returned on any surface. Sekretessmarkering and skyddad folkbokföring suppress every address — folkbokföringsadress, särskild postadress, utlandsadress, kontaktadress — in the REST response, in the MCP tool result and in anything Gungner stores. The marker is the data, and it is markers.protected: none | sekretess | skyddad. This is enforced where the canonical object is built, not on the way out of a particular surface, so there is no surface on which it does not hold.

  • Deceased and emigrated are markers with dates — markers.deceased_date, markers.emigrated_date. A foreign address is returned for an emigrated person only when no protection marker is set.

  • A person profile is never served stale. Unlike the KYB reads in §4, when SPAR is unavailable and the cached profile has passed its window this returns 503 rather than an out-of-date profile: a profile cached before a protection marker appeared still holds an address, and the cache window is what bounds that. stale is therefore always false on this route; the envelope keeps the field for uniformity.

  • Metering. Person lookups are metered per tenant against their own daily ceiling, separate from the paid Bolagsverket budget in §9. Exhausting it gives the code in errors.md, exactly as §9 describes.

  • Person data is cached per tenant only and never shared between tenants — see §2’s last paragraph, which this makes structural rather than contractual. Persons outside Sweden: §13.

Each tenant has a daily paid-call budget shared by on-demand reads and scheduled monitoring. Scheduled work may take only the tenant’s monitoring_budget_share of it (default half) so on-demand reads keep headroom; work that reaches that share emits refresh.deferred and resumes in the next window rather than failing. When the whole budget is exhausted, reads degrade to stale: true where a cached value exists and otherwise fail with the code shown in errors.md. Rate limiting is deferred; every request does constant work regardless of outcome. Unauthenticated or unattributable requests are recorded in a security log, not in the tenant’s audit log.

errors.md lists every code with its fix. 401 = no valid token, including a credential that is revoked, expired or rotated out; 403 = valid token, wrong tenant, scope (forbidden), country (country_not_granted when the grant does not carry it, country_not_provisioned when the tenant is not provisioned for it) or missing legal basis (permit); 404 when the resource is absent for that tenant, and equally when it lies outside the grant’s countries and was asked for by id — the two are indistinguishable, by design; a request that names a country the grant does not carry answers 403 country_not_granted instead; 501 country_not_supported = the country is known but the capability asked for is not built for it yet (§13): do not retry, the code clears when the connector ships and CHANGELOG.md says so. Brayns should surface the code, not the message, in its own logs.

contract.json is the machine-readable snapshot of the interface; VERSION is semver (additions minor, changes and removals major, deprecations carried one minor version); CHANGELOG.md names every change. A change that fails Gungner’s own contract test cannot merge without a version bump, so Brayns can pin a version and read the changelog to move.

Exceptions. Twice the interface has shipped a behaviour change as a minor version: 1.5.0 and 1.6.0. Each was made while no integrator held a credential on the version before — the only premise under which it is done — and CHANGELOG.md names each change and the premise. In 1.6.0 the four changes are: kyb:read no longer opens the refresh route (§2); every read is bounded by the grant’s countries (§13); a well-formed cursor beyond the newest event the grant can see answers 200 with an empty page and the same cursor back instead of 400 (§6) — a security fix, since with reads bounded by country that refusal told a grant how many events it could not read; and an ad-hoc screening check whose name carries a personal identity number is refused with 400 name_is_identifier where it used to be screened, since a name-only check is visible to every grant of the tenant (§13). From the first external integrator on, the rule above holds without exception.

sandbox.md walks the demo cockpit end to end (consumer, token, three routes, events, MCP via Claude Code). Questions that the pack does not answer go to Gungner; every such question is a docs defect and is fixed in the pack, not answered privately.

The two sandbox tenants, finshark-sandbox and rato-sandbox, are provisioned by Gungner on paper terms; their grants carry the six SPEC-002 scopes, and kyb:refresh is added on request. Nothing in the sandbox is production data.

Gungner’s data plane is multi-country; Sweden and Lithuania are the first two instances of one model, and adding a country is data and connectors, never a schema change.

  • Every identifier, cached object, event, subscription, screening result and grant carries a country. A screening check made on a name alone carries none; an object with no country is visible to every grant of the tenant.
  • A tenant is provisioned for a set of countries; a grant carries an explicit, non-empty subset. A grant sees only what lies in its countries — identifiers, objects, events, subscriptions and screening results alike. A read by id of something outside them answers 404, indistinguishable from a resource that does not exist, so a grant cannot learn what the tenant holds in countries it was not given; a request that names a country the grant does not carry answers 403 country_not_granted, and one that names a country the tenant is not provisioned for answers 403 country_not_provisioned. No read in the interface is unbounded: a request that names no country is bounded by the grant’s whole set, not by “all”.
  • A subject with identifiers in several countries is one subject. Under a grant that carries only some of those countries, the identifiers and objects outside them are not returned; the response is shaped by the grant, not by the subject.
  • Coverage is per country and per capability. Where the capability asked for is not built for a country, the answer is 501 country_not_supported and nothing is created or changed; CHANGELOG.md announces each capability as it lands, and the docs site’s coverage matrix shows the current state.
  • In 1.6.0: Sweden — everything in this file. Lithuania — identifiers (asmens kodas, įmonės kodas) resolve to subjects and grants can carry the country; company data, monitoring and screening for Lithuanian subjects arrive with the Lithuanian connectors, open data first and the restricted registers under the tenant’s own agreement after, each announced in CHANGELOG.md. Other countries: later.