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.
1. What Gungner is to Brayns
Section titled “1. What Gungner is to Brayns”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.
2. Delegation and scopes
Section titled “2. Delegation and scopes”- 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:policyalso 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.mdshows which routes each scope opens and which scopes are live in the current version.kyc:readbecame 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:readarrived in v1.3.0 as the read half ofmonitoring: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:runarrived in v1.4.0 with screening (§9);screening:runis 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 acceptmonitoring:readin addition to the scopes that already opened them, so nothing a consumer could call before stops working.kyb:refresh(v1.6.0) opensPOST /v1/companies/{orgnr}/refreshand therefresh_companytool, and nothing else. Until 1.6.0kyb:readopened 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 underkyb:readreceives403 forbiddenfrom 1.6.0 until its grant carrieskyb: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).
3. Subject ids
Section titled “3. Subject ids”- 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>andse:<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.mdshows 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.
4. Reads: envelope, staleness, refresh
Section titled “4. Reads: envelope, staleness, refresh”- Every data read returns
{stale: boolean, data}.stale: truemeans 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.mdcome from a verified upstream contract; fields from a provisional contract can change shape when verification lands (additive where possible, announced inCHANGELOG.md). In v1: Bolagsverket free tier verified; Bolagsverket paid tier provisional; signatory rules are text passthrough (a structured form is a later, additive change).
5. Evidence (ADR-010)
Section titled “5. Evidence (ADR-010)”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.
6. Events and cursors (ADR-008)
Section titled “6. Events and cursors (ADR-008)”- 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.changedis 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, andtrigger(on_demandwhen your own refresh call caused it,scheduledwhen the cadence did).GET /v1/events?types=filters the same stream by type — cursors are unaffected, so a filtered page can come back short whilenext_cursoradvances. 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 waytypes=bounds it: a page can come back short whilenext_cursoradvances, and a cursor stays valid across grants of the same tenant.
7. Monitoring (SPEC-005 / ADR-011)
Section titled “7. Monitoring (SPEC-005 / ADR-011)”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.
8. Persons (SPEC-003b)
Section titled “8. Persons (SPEC-003b)”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 akyc:read-only consumer on MCP had no tool that returned aref—get_subjectiskyb:read— and left the fix to the spec that wanted it.lookup_personis that fix: it resolves a personnummer or samordningsnummer and returns the profile in one call, the person mirror oflookup_company, underkyc:readalone. It takes the identifier’s scheme explicitly, asPOST /v1/subjects/resolvedoes;reference.mdnames 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 gives403 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 gatePOST /v1/subscriptionswithkind: 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
503rather 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.staleis therefore alwaysfalseon 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.
9. Budgets, degradation, limits
Section titled “9. Budgets, degradation, limits”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.
10. Errors
Section titled “10. Errors”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.
11. Versioning
Section titled “11. Versioning”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.
12. Sandbox and support
Section titled “12. Sandbox and support”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.
13. Countries (ADR-017)
Section titled “13. Countries (ADR-017)”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 answers403 country_not_granted, and one that names a country the tenant is not provisioned for answers403 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_supportedand nothing is created or changed;CHANGELOG.mdannounces 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.