Pular para o conteúdo

Integration Attribution Contract — AttributionContext

integration specs/integration/attribution-contract.kmd

Define o **contrato de atribuição e proveniência** que todo sistema externo (saude-publica e outros) carrega ao integrar com o Koder Iris via SDK. Separa dois problemas que costumam ser confundidos: (A) **certeza de identidade do paciente** — resolvida por chave qualificada `(issuer, patientId)` + vínculo por accession, um problema de *correção*; e (B) **proveniência/atribuição** — resolvida por um **envelope genérico** (`AttributionContext`: `source_system` + `org_path` de profundidade arbitrária + `operator` + ação/tempo), um problema de *auditoria*. O envelope é obrigatório e fail-closed no SDK, imutável e encadeado na trilha à prova de adulteração (RFC-036 C15), com PII fora da telemetria (observability-first) e um subconjunto espelhado em tags DICOM para portabilidade. É a materialização, no contrato de SDK, da pré-condição de **isolamento de tenant** que gateia a custódia central de PHI (stack#439).

Quando esta spec se aplica

Triggers primários

Todos os triggers

Corpo da especificação

Contrato de Atribuição de Integração — AttributionContext

1. Escopo e o corte em dois problemas

Todo sistema externo que escreve no Iris (submeter ordem/worklist ou resultado de exame) via SDK DEVE carregar um AttributionContext. O contrato resolve dois problemas distintos, com mecanismos distintos:

ProblemaNaturezaMecanismo
A"A que paciente este dado pertence?"CorreçãoChave qualificada (issuer, patientId) + vínculo por accession (§3)
B"Quem/o-que originou este dado?"AuditoriaEnvelope AttributionContext (§4), imutável e encadeado (§5)

Confundir os dois leva a erro: proveniência não identifica paciente, e identidade de paciente não é metadado de auditoria mutável.

2. Formato e degrau

AttributionContext é um schema sobre JSON (degrau 2 da escada de policies/language-and-format-creation.kmd) — não um formato novo. Apoia-se em padrões externos consolidados (R6): DICOM Issuer of Patient ID, HL7 assigning authority, e o modelo W3C PROV (Agent/Activity/Entity) como inspiração conceitual, sem carregar sua ontologia. Registrado em registries/languages-and-formats.md no mesmo PR (R4).

3. Identidade de paciente (problema A) — normativo

R1 — Chave de paciente é sempre qualificada. O identificador de paciente NUNCA é o patientId cru. A chave é o par (issuer, patientId), onde issuer é a autoridade emissora do identificador (a assigning authority HL7 / Issuer of Patient ID DICOM). O prontuário do saude-publica só é único dentro de um município → o issuer é o município (ou a instância do saude-publica). Sem issuer, patientId de tenants distintos colide num store central.

R2 — Vínculo por accession, não por adivinhação. A ligação resultado→paciente é feita pelo accession number que a worklist cunhou, não pelo PatientID que o equipamento devolve (que vem vazio/errado — o próprio código admite). O resultado (C-STORE) carrega o accession; o Iris resolve accession → ordem → (issuer, patientId). Um resultado sem accession resolvível entra na fila de não-vinculados para reconciliação humana, nunca é auto-atribuído por heurística de nome/ID.

R3 — issuer deriva do org_path, não é campo solto. O issuer de um patientId é determinado pelo nível-tenant do AttributionContext (§4) que criou a ordem — garantindo que identidade de paciente e tenant sejam a mesma verdade, com um só caminho (R3 da escada de formatos).

4. Proveniência (problema B) — o envelope AttributionContext

O envelope é genérico: não modela o principal de nenhum sistema específico.

AttributionContext {
  "schema_version": "1",                    // o envelope evolui sem quebrar integrações (R11)

  "source_system": {                        // qual software originou, e onde rodava
    "name":        "saude-publica",
    "version":     "4.2.10+build.917",      // OBRIGATÓRIO (R6)
    "instance_id": "sp-botucatu-01",        // instância lógica (LXC/container/tenant do integrador)
    "host":        "sp-lxc-3.botucatu",     // hostname do servidor
    "ip":          "10.4.0.12",             // IP de origem observado
    "environment": "prd"                    // prd | stg | dev
  },

  "org_path": [                             // hierarquia ORDENADA, profundidade arbitrária (R7)
    { "kind": "municipality", "id": "3506003", "label": "Botucatu" },
    { "kind": "facility",     "id": "UBS-07",  "label": "UBS Vila dos Lavradores" },
    { "kind": "sector",       "id": "CARDIO",  "label": "Cardiologia" }
  ],

  "operator": {                             // quem operou (R8: opaco com mínimo)
    "subject":      "enio.silva",           // OBRIGATÓRIO — o login no sistema-origem
    "display_name": "Ênio Silva",
    "claims":       { "cns": "…", "role": "tecnico" }   // bag opaca; o Iris guarda, não interpreta
  },

  "action":      "submit-result",           // submit-order | submit-result | amend | …
  "occurred_at": "2026-07-20T15:01:00Z"     // instante do ato no sistema-origem
}

R6 — source_system.version é obrigatório. Sem a versão do sistema-origem no momento do envio/recebimento, o registro clínico não é audível ("qual versão estava quando enviou os dados deste exame?"). O SDK recusa a chamada sem ela.

R7 — org_path é lista tipada, ordenada, de profundidade arbitrária. Cada nível é { kind, id, label }. O SDK não interpreta kind — é rótulo do integrador. saude-publica preenche [municipality, facility, sector]; um sistema login/senha preenche [] ou [{tenant}]. O nível-topo do org_path é o tenant que o Iris usa no isolamento (kdb RLS por kdb.tenant) e como issuer (R3). org_path vazio ⇒ o source_system.instance_id é o tenant.

R8 — operator é opaco com mínimo obrigatório. Exigido: subject (o login). display_name e claims são aceitos como bag e guardados sem interpretação — assim login/senha e operador/município/unidade/setor cabem sem o Iris privilegiar o modelo de nenhum integrador. O Iris nunca faz autorização com base em claims; são dado de auditoria.

5. Garantias (o que faz disto contrato, não boa-vontade) — normativo

R9 — Obrigatório e fail-closed no SDK. Toda escrita exige o AttributionContext. O SDK recusa (erro, não default silencioso) se faltar o mínimo: source_system.name + source_system.version, operator.subject, chave de paciente qualificada (§3) e — para submit-order — o nível-topo do org_path. É assim que o Iris sempre conhece a proveniência.

R10 — Imutável e encadeado. A atribuição é gravada no ato do submit, presa ao estudo/ordem, e dobrada na trilha à prova de adulteração do daemon (cadeia seq/prev/hash já existente; RFC-036 C15). Correção posterior é um evento amend novo na cadeia (com sua própria atribuição), nunca reescrita do registro original.

R11 — Versionado. schema_version permite integradores em versões diferentes. O Iris aceita versões conhecidas e rejeita desconhecidas com erro explícito; campos novos são aditivos dentro de uma major.

R12 — PII fora da telemetria. operator.subject, operator.claims e a chave de paciente são PII → vivem no store de atribuição auditado, nunca em métrica/label/trace/log estruturado de telemetria (cardinalidade + vazamento; observability-first.kmd). O trace_id correlaciona atribuição e telemetria sem copiar o PII para o lado observável. Retenção do dado de operador segue identity-data-retention.kmd.

6. Subconjunto DICOM-nativo (portabilidade) — normativo

R13 — Espelhar o subconjunto portátil em tags DICOM. Para o exame ser auto-descritivo fora do Iris, o SDK/daemon grava, no objeto DICOM, o subconjunto abaixo. O envelope rico (IP, instance_id, subject, schema_version) excede os slots limpos do DICOM e vive só no store de atribuição.

Campo do envelopeTag DICOMVR
issuer (R1)Issuer of Patient ID (0010,0021) + Qualifiers Seq (0010,0024)LO / SQ
org_path[municipality/facility]Institution Name (0008,0080)LO
org_path[sector]Institutional Department Name (0008,1040)LO
operator.display_nameOperators' Name (0008,1070)PN
source_system.name+versionContributing Equipment Sequence (0018,A001) (Purpose = SOURCE)SQ
accession (R2)Accession Number (0008,0050)SH

7. Exemplos trabalhados

7.1 saude-publica (hierárquico)

org_path = [municipality Botucatu, facility UBS-07, sector CARDIO], operator.subject = enio.silva, source_system = saude-publica 4.2.10. Tenant = municipality:3506003; issuer do prontuário = esse tenant; accession BTC-FLUXO-001 liga o C-STORE à ordem.

7.2 Sistema login/senha (flat)

org_path = [] (ou [{ kind:"tenant", id:"clinica-x" }]), operator.subject = dr.house, source_system = clinicsoft 2.0. Tenant = source_system.instance_id (ou o único nível). Mesmo envelope, zero campo específico do saude-publica — a abstração fecha.

8. Testes

  • T1 — Submeter resultado sem AttributionContext → SDK recusa (R9). (behavioral)
  • T2 — Dois patientId iguais sob issuer distintos → dois pacientes distintos no store (R1). (behavioral)
  • T3 — C-STORE sem accession resolvível → fila de não-vinculados, nunca auto-atribuído (R2). (behavioral)
  • T4org_path de profundidade 0, 1 e 3 → todos aceitos; nível-topo vira tenant (R7). (behavioral)
  • T5 — Métrica/trace emitidos por um submit → nenhum contém operator.subject/patientId (R12). (estrutural)
  • T6 — Objeto DICOM gravado carrega Issuer of Patient ID + Contributing Equipment (R13). (golden)
  • T7amend de uma atribuição → novo elo na cadeia; registro original intacto e verificável (R10). (behavioral)
  • T8schema_version desconhecida → erro explícito, não aceitação silenciosa (R11). (behavioral)

9. Relação com a decisão de custódia (stack#439)

Esta spec é independente da forma de consumo (F2/F3/F4): a atribuição é necessária em qualquer topologia. Mas ela é a materialização, no contrato de SDK, da pré-condição de isolamento de tenant que gateia a custódia central de PHI (Forma 2 / F3 — stack#439). Implementá-la corretamente é pré-requisito, não consequência, de qualquer decisão de centralizar. A decisão jurídica (Koder como operadora/controladora) permanece do owner; esta spec não a antecipa.

10. Attestation — signed submissions (normative)

Sections 3–9 make attribution declared. This section makes it attested: the holder of the API key alone cannot forge a submission's authorship.

R14 — Every write is signed. The SDK produces a detached JWS (EdDSA/Ed25519) over a canonical digest of sha256(DICOM object) ‖ canonical(AttributionContext) ‖ occurredAt ‖ nonce. The private key never leaves the integrator. Iris verifies with the integrator's registered public key (via JWKS — reuse engines/sdk/go/auth). Unverifiable signature → reject (fail-closed). The signature therefore binds the exam and the attribution together: neither the patient nor the provenance can be swapped without breaking it.

R15 — Replay protection. The signed payload carries a nonce and occurredAt; Iris keeps a short-window seen-nonce cache and rejects duplicates and stale timestamps.

Honest boundary (do not oversell). A signature proves key possession, integrity, authorship, and non-repudiation, and binds attribution↔exam. It does NOT prove network location. The source_system.ip is an observed claim: it is enforced as a policy gate + anomaly alert (a valid signature from an un-allowlisted IP is rejected/alerted, RFC-036 §7), never presented as cryptographic proof. mTLS (client cert) is the closest channel anchor and MAY be required per integrator.

R14a — The verification result is auditable. The kid, verified-at time, and observed IP are folded into the immutable audit chain (R10), so any exam can later be shown to have been signed by a specific key and verified at a specific time.

11. Trust anchor / enrollment (normative — guarantor choice is owner-decision)

Cryptography never establishes the key↔entity binding by itself: a keypair is anonymous. The binding "this key belongs to legal entity Y" is always created by an enrollment process run by an authority — that authority is the guarantor. Two tiers compose:

R16 — Baseline: Koder as Registration Authority. At onboarding, Koder performs KYB (CNPJ + contract + responsible person) and registers the integrator's Ed25519 public key bound to {entity, CNPJ, system, allowed tenants}. Guarantor = Koder's onboarding process. Pragmatic floor for Koder-hosted (Form 2).

R17 — Legal tier: ICP-Brasil e-CNPJ. Where juridical non-repudiation matters (the laudo, or a client that demands it), the enrollment key is itself attested by an ICP-Brasil e-CNPJ certificate (pessoa jurídica), validated against the AC-Raiz chain — reuse services/crypto/signer. e-CPF (pessoa física) vs e-CNPJ (pessoa jurídica) is the native dev-vs-company distinction: require e-CNPJ to put the company on the hook.

R18 — Revocation is mandatory. Whatever the guarantor, the binding is only as good as key protection + revocation. CRL/OCSP for ICP-Brasil (signer already does it); a revocation list for Koder-RA. Without revocation the anchor is decorative.

The enrolled identity is recorded on source_system.enrolled_identity = { authority: "koder-ra" | "icp-brasil", entity_id: <CNPJ>, kid } and enters the audit chain, so every exam carries who vouched for the key and which entity it maps to.

Owner-decision. Which guarantor is required per consumption form (Koder-RA floor everywhere; ICP-Brasil e-CNPJ mandatory for the laudo, optional-per-client for integration attestation) has contractual/legal weight and is the owner's call. This spec defines the mechanism, not the mandate.

12. Third-party witnessing (normative — which witnesses is owner-decision)

A trusted third party (TTP) witnesses the communication. Its value comes from being controlled by neither party — so Koder, as a party to its own inbound, cannot witness it; a neutral external witness is required for that link. Witnesses split by what they attest, and for PHI every witness sees only the hash, never the content (LGPD):

R19 — Time witness (RFC 3161 TSA). Timestamp the hash of each exam/laudo at an accredited TSA (ICP-Brasil Carimbo do Tempo) — reuse services/crypto/signer. Proves existence-at-T independent of Koder's clock.

R20 — Immutability witness (public ledger anchor). Periodically anchor the hash of the audit-chain head to a public ledger (OpenTimestamps / Bitcoin). The internal chain proves order; the public anchor proves Koder did not rewrite history after the fact — verifiable by anyone, forever, at ~zero cost.

R21 — Sector hub (long-term). For Brazilian clinical interop, RNDS (DATASUS) is the real third-party health hub; if Iris feeds the national network, RNDS is the recognized witness of the clinical exchange (under its own legal framework — the one case a witness legitimately sees content).

Honest boundary. A witness attests existence/delivery, not truth-of-content: a TSA proves "this exact payload passed at T signed by key K", not that the exam is real or that operator X truly performed it. Truth-of-content rests on the signer.

Owner-decision. Which witnesses to require (TSA always? OpenTimestamps anchor? RNDS when?) is the owner's call. Committed architecture: this witnessing capability is a standalone Stack component (services/crypto/veredix), NOT Iris-internal — Iris is its first consumer via the SDK. Both stages (internal infra

  • external product) are committed in stack-RFC-042; the attestation slices in dicom#003 CALL that component, they do not contain its logic.

13. Attestation tests

  • T9 — A submission with an invalid/absent signature is rejected (R14). (behavioral)
  • T10 — Tampering with either the DICOM bytes or the AttributionContext after signing fails verification (R14 binding). (behavioral)
  • T11 — A replayed (duplicate-nonce) submission is rejected (R15). (behavioral)
  • T12 — A valid signature from an un-allowlisted IP is rejected/alerted, not accepted as proof (R14 boundary). (behavioral)
  • T13 — A submission whose enrollment key is revoked is rejected (R18). (behavioral)

Referências