SGHI FHIR Profile Implementation Guide
0.1.0 - ci-build

SGHI FHIR Profile Implementation Guide, published by Kathurima Kimathi. This guide is not an authorized publication; it is the continuous build for version 0.1.0 built by the FHIR (HL7® FHIR® Standard) CI Build. This version is based on the current content of https://github.com/savannahghi/sil_fhir_profile_ig/ and changes regularly. See the Directory of published versions

StructureMap: Cancer Treatment Consent Extraction

Official URL: https://fhir.slade360.co.ke/fhir/StructureMap/ExtractCancerTreatmentConsent Version: 0.1.0
Active as of 2026-10-06 Computable Name: ExtractCancerTreatmentConsent

The cancer centre's informed consent as one Consent holding everything on the form, built only once the patient has signed: the patient as grantor, the patient and the doctor who recorded it (the response's author) as its grantees, that doctor and any interpreter as its two verifications, and the treatment and what was explained as its provisions.

/// url = 'https://fhir.slade360.co.ke/fhir/StructureMap/ExtractCancerTreatmentConsent'
/// name = 'ExtractCancerTreatmentConsent'
/// title = 'Cancer Treatment Consent Extraction'
/// status = 'active'
/// description = 'The cancer centre\'s informed consent as one Consent holding everything on the form, built only once the patient has signed: the patient as grantor, the patient and the doctor who recorded it (the response\'s author) as its grantees, that doctor and any interpreter as its two verifications, and the treatment and what was explained as its provisions.'

uses "http://hl7.org/fhir/StructureDefinition/QuestionnaireResponse" alias QR as source
uses "http://hl7.org/fhir/StructureDefinition/Bundle" alias Bundle as target
uses "http://hl7.org/fhir/StructureDefinition/Consent" alias Consent as target

group ExtractCancerTreatmentConsent(source qr : QR, target bundle : Bundle) {
  qr -> bundle.type = 'transaction' "setBundleType";
  // ── The Consent ─────────────────────────────────────────────────────────
  // Guarded on the patient having signed, as the procedure consent is on the
  // consenter. A Consent built from a form nobody signed asserts a permission
  // that was never given.
  qr where (item.where(linkId = 'patient').item.where(linkId = 'patient/signed').answer.value = true) ->  bundle.entry as entry,  entry.resource = create('Consent') as consent then {
    qr ->  consent,  entry then BuildConsentBase(qr, consent, entry) "base";
    qr -> consent.status = 'active' "status";
    qr -> consent.decision = 'permit' "decision";
    qr -> consent.category as cat then {
      qr -> cat.coding as cg then {
        qr -> cg.system = 'http://loinc.org' "sys";
        qr -> cg.code = '64293-4' "cd";
        qr -> cg.display = 'Procedure consent Document' "dsp";
      } "coding";
      qr -> cat.text = 'Consent to systemic anti-cancer therapy' "categoryText";
    } "category";
    // The patient consents for themselves: the form has no line for anyone
    // else to sign on their behalf.
    qr.subject as s where ((reference.exists() and (reference.startsWith('Organization/') or reference.startsWith('Patient/'))) or (reference.empty() and (type.empty() or ('/' + type).endsWith('/Organization') or ('/' + type).endsWith('/Patient')))) -> consent.grantor = s "grantorPatient";
    // The grantees are both parties to the consent: the patient, written here and only
    // when the subject is one, and the practitioner who recorded it, the response's
    // author, which ConformConsent adds. The stamped copy never names the patient
    // itself; see mapmeta's WHO_NEVER.
    qr.subject as s where (reference.startsWith('Patient/') and ((reference.exists() and (reference.startsWith('Patient/') or reference.startsWith('Organization/') or reference.startsWith('Practitioner/') or reference.startsWith('PractitionerRole/'))) or (reference.empty() and (type.empty() or ('/' + type).endsWith('/Patient') or ('/' + type).endsWith('/Organization') or ('/' + type).endsWith('/Practitioner') or ('/' + type).endsWith('/PractitionerRole'))))) -> consent.grantee = s "granteePatient";
    // The signing time, from the form, not qr.authored. Consent.date is a
    // `date` and this is a date and time, so it goes to period.start.
    qr.item as pg where (linkId = 'patient') then {
      pg.item as it where ((linkId = 'patient/signed-at') and answer.value.exists()) then {
        it.answer first as a then {
          a.value as t ->  consent.period as per,  per.start = t "signedPeriodStart";
        };
      } "signedAtRule";
    } "patientGroup";
    // ── The verifications ─────────────────────────────────────────────────
    // The doctor's, and the interpreter's when one was used.
    // The doctor who explained the treatment is the response's author. The form
    // stopped asking for the doctor's name, designation and signature on
    // 2026-10-06, because whoever records the consent is that doctor. So the
    // verification is made when the response has an author who is a person, a
    // Practitioner or a PractitionerRole (an Organization is not a doctor), and
    // it is verified by them: recording the form is their attestation. No such
    // author, no doctor's verification. Dated with the patient's signing, the
    // only time the form records.
    qr.author as au where ((reference.exists() and (reference.startsWith('Practitioner/') or reference.startsWith('PractitionerRole/'))) or (reference.empty() and (type.empty() or ('/' + type).endsWith('/Practitioner') or ('/' + type).endsWith('/PractitionerRole')))) -> consent.verification as ver then {
      au -> ver.verified = true "verified";
      au -> ver.verificationType as vt then {
        au -> vt.coding as vc then {
          au -> vc.system = 'https://fhir.slade360.co.ke/fhir/CodeSystem/concept-codesystem' "sys";
          au -> vc.code = 'consent-verified-by-doctor' "cd";
          au -> vc.display = 'Verified by the doctor who explained the treatment' "dsp";
        } "coding";
        au -> vt.text = 'Doctor' "text";
      } "doctorVerificationType";
      au where ((reference.exists() and (reference.startsWith('Organization/') or reference.startsWith('Practitioner/') or reference.startsWith('PractitionerRole/'))) or (reference.empty() and (type.empty() or ('/' + type).endsWith('/Organization') or ('/' + type).endsWith('/Practitioner') or ('/' + type).endsWith('/PractitionerRole')))) -> ver.verifiedBy = au "verifiedBy";
      qr.subject as s where ((reference.exists() and (reference.startsWith('Patient/'))) or (reference.empty() and (type.empty() or ('/' + type).endsWith('/Patient')))) -> ver.verifiedWith = s "verifiedWith";
      qr.item as pg2 where (linkId = 'patient') then {
        pg2.item as st where ((linkId = 'patient/signed-at') and answer.value.exists()) then {
          st.answer first as sa then {
            sa.value as t -> ver.verificationDate = t "verificationDate";
          } "signedAtAnswer";
        } "signedAtRule";
      } "patientGroupForVerification";
    } "doctorVerification";
    // The interpreter, if one was used: whether the explanation reached the patient
    // in a language they understood is what makes the consent informed. Verified
    // when they have signed. It names no verifier, because the interpreter is a name
    // on the form, not a record anything could point at; the name is in its type.
    qr.item as ig where (linkId = 'interpreter') then {
      ig.item as it where ((linkId = 'interpreter/name') and answer.value.exists()) then {
        it.answer first as a then {
          a.value as name where ($this is string) -> consent.verification as iv then {
            name -> iv.verified = false "verifiedUnlessSigned";
            ig.item as sg where ((linkId = 'interpreter/signed') and (answer.value = true)) then {
              sg -> iv.verified = true "interpreterSigned";
            } "interpreterSignedRule";
            name -> iv.verificationType as ivt then {
              name -> ivt.coding as ic then {
                name -> ic.system = 'https://fhir.slade360.co.ke/fhir/CodeSystem/concept-codesystem' "sys";
                name -> ic.code = 'consent-interpreter-used' "cd";
                name -> ic.display = 'Interpreter used for the consent discussion' "dsp";
              } "coding";
              name -> ivt.text = append('Interpreter: ', name) "text";
            } "interpreterVerificationType";
            qr.subject as s where ((reference.exists() and (reference.startsWith('Patient/'))) or (reference.empty() and (type.empty() or ('/' + type).endsWith('/Patient')))) -> iv.verifiedWith = s "verifiedWith";
            qr.item as pg3 where (linkId = 'patient') then {
              pg3.item as st where ((linkId = 'patient/signed-at') and answer.value.exists()) then {
                st.answer first as sa then {
                  sa.value as t -> iv.verificationDate = t "verificationDate";
                } "signedAtAnswer";
              } "signedAtRule";
            } "patientGroupForInterpreter";
          } "interpreterVerification";
        };
      } "nameRule";
    } "interpreterGroup";
    // ── The provisions ────────────────────────────────────────────────────
    // One per kind of therapy ticked, so a consent to hormonal therapy cannot
    // be read as covering chemotherapy. The local code is what the form
    // offered; the LOINC answer code beside it is what another system will
    // recognise, and LOINC is what SGHIConsent binds provision.code to
    // (consent-content-code, required). Both LOINC codes come from LOINC's
    // own list of cancer treatments, LL1479-6, and the display is LOINC's to
    // the letter, trailing slash included. LOINC has no code for targeted
    // therapy, so that one carries the local code alone.
    // The regimen and what was explained are provisions too, each labelled.
    // Consent has no note in R5, and this is where the procedure consent puts
    // the same prose.
    qr.item as tg where (linkId = 'treatment') then {
      tg.item as it where (linkId = 'treatment/therapy') then {
        it.answer as a -> consent.provision as prov then {
          a.value as cv -> prov.code as pc then {
            cv -> pc.coding = cv "localCoding";
            cv.display as d -> pc.text = d "text";
            cv where (code = 'anti-cancer-chemotherapy') -> pc.coding as loinc then {
              cv -> loinc.system = 'http://loinc.org' "sys";
              cv -> loinc.code = 'LA6172-6' "cd";
              cv -> loinc.display = 'Chemotherapy' "dsp";
            } "chemotherapyLoinc";
            cv where (code = 'anti-cancer-hormonal-therapy') -> pc.coding as loinc then {
              cv -> loinc.system = 'http://loinc.org' "sys";
              cv -> loinc.code = 'LA16052-5' "cd";
              cv -> loinc.display = 'Hormonal therapy/' "dsp";
            } "hormonalLoinc";
          } "therapyCode";
        } "therapyProvision";
      } "therapyRule";
      tg.item as it where ((linkId = 'treatment/regimen') and answer.value.exists()) then {
        it.answer first as a then {
          a.value as txt ->  consent.provision as prov,  prov.code as pc,  pc.text = append('Drug regimen: ', txt) "regimenProvision";
        };
      } "regimenRule";
      tg.item as it where ((linkId = 'treatment/diagnosis') and answer.value.exists()) then {
        it.answer first as a then {
          a.value as txt ->  consent.provision as prov,  prov.code as pc,  pc.text = append('Diagnosis explained: ', txt) "diagnosisProvision";
        };
      } "diagnosisRule";
      tg.item as it where ((linkId = 'treatment/intended-purpose') and answer.value.exists()) then {
        it.answer first as a then {
          a.value as txt ->  consent.provision as prov,  prov.code as pc,  pc.text = append('Intended purpose explained: ', txt) "purposeProvision";
        };
      } "purposeRule";
      tg.item as it where ((linkId = 'treatment/side-effects-and-risks') and answer.value.exists()) then {
        it.answer first as a then {
          a.value as txt ->  consent.provision as prov,  prov.code as pc,  pc.text = append('Side effects and risks explained: ', txt) "sideEffectsProvision";
        };
      } "sideEffectsRule";
    } "treatmentGroup";
  } "createConsent";
}

// ─────────────────────────────────────────────────────────────────────────────
// Shared scaffolding. Repeated in each map rather than imported: the transform
// engine resolves `imports` against whatever StructureMaps happen to be on the
// server, and a form that stops extracting because a shared map was not
// uploaded is a worse failure than a duplicated group.
// ─────────────────────────────────────────────────────────────────────────────
group BuildConsentBase(source qr : QR, target r : Consent, target entry) {
  qr -> r then SetResponseProvenance(qr, r) "responseProvenance";
  qr.id as qid where ($this.hasValue()) ->  r.sourceReference = create('Reference') as srcRef,  srcRef.reference = qid "linkQuestionnaireResponse";
  qr -> r.id = uuid() then SetConsentFullUrl(r, entry) "idAndFullUrl";
  qr -> entry.request as request then {
    qr -> request.method = 'POST' "requestMethod";
    qr -> request.url = 'Consent' "requestUrl";
  } "entryRequest";
  qr.subject as s where ((reference.exists() and (reference.startsWith('Patient/'))) or (reference.empty() and (type.empty() or ('/' + type).endsWith('/Patient')))) -> r.subject = s;
  qr -> r then ConformConsent(qr, r) "profileConformance";
}

group SetConsentFullUrl(source r : Consent, target entry) {
  r.id as id -> entry.fullUrl = append('https://fhir.slade360.co.ke/fhir/Consent/', id) "assignFullUrl";
}

// ── Provenance and tenancy ───────────────────────────────────────────────────
// Two tags on everything this map creates. The first says which questionnaire
// produced it, so a form that reopens mid-admission recalls its own earlier
// entries and not another form's. The second copies the tenant tags off the
// response, because nothing else on the path puts them there. The response's
// identifier is copied the same way.
// The code is the form's document type, not the questionnaire's id: every
// environment mints its own ids, and there is only one set of maps, so an id
// here would tie the tag to whichever environment stamped it.
// ── Provenance and tenancy ───────────────────────────────────────────────────
group SetResponseProvenance(source qr : QR, target r) {
  qr -> r.meta as m then {
    qr -> m.tag as t then {
      qr -> t.system = 'http://slade360edi.com/questionnaire-provenance' "provenanceSystem";
      qr -> t.code = 'cancer-treatment-consent' "provenanceCode";
      qr -> t.display = 'Cancer centre informed consent' "provenanceDisplay";
    } "provenanceTag";
  } "provenanceMeta";
  // Every tag the response carries, onto the resource. `meta` is 0..1, so this
  // lands on the same meta the rule above created rather than a second one.
  qr.meta as qm then {
    qm.tag as qt ->  r.meta as m,  m.tag = qt "copyTag";
  } "tenantTags";
  // The response's identifiers, as they are. Every profile wants one, and it is
  // the response's to give: nothing here mints one, so a response without an
  // identifier extracts resources without one. One whose assigner is not of a
  // type the profiles allow there is left behind whole, not altered.
  qr.identifier as qid where (assigner.empty() or assigner.where(((reference.exists() and (reference.startsWith('Organization/'))) or (reference.empty() and (type.empty() or ('/' + type).endsWith('/Organization'))))).exists()) -> r.identifier = qid;
}

// ── Conformance to the IG profiles ───────────────────────────────────────────
// What the IG's profiles require that the response itself can supply. Called at
// the end of each base group. The encounter and every who-element are the
// response's own; see mapmeta.py for what is and is not here.
// ── Conformance to the IG profiles ───────────────────────────────────────────
group ConformConsent(source qr : QR, target r) {
  qr.authored as t ->  evaluate(t, $this.toString().substring(0, 10)) as day,  r.date = cast(day, 'date') "date";
  qr.author as au where ((reference.exists() and (reference.startsWith('Organization/') or reference.startsWith('Practitioner/') or reference.startsWith('PractitionerRole/'))) or (reference.empty() and (type.empty() or ('/' + type).endsWith('/Organization') or ('/' + type).endsWith('/Practitioner') or ('/' + type).endsWith('/PractitionerRole')))) -> r.grantee = au "granteeFromAuthor";
  qr where (author.where(((reference.exists() and (reference.startsWith('Organization/') or reference.startsWith('Practitioner/') or reference.startsWith('PractitionerRole/'))) or (reference.empty() and (type.empty() or ('/' + type).endsWith('/Organization') or ('/' + type).endsWith('/Practitioner') or ('/' + type).endsWith('/PractitionerRole'))))).exists().not()) then {
    qr.source as src where ((reference.exists() and (reference.startsWith('Organization/') or reference.startsWith('Practitioner/') or reference.startsWith('PractitionerRole/'))) or (reference.empty() and (type.empty() or ('/' + type).endsWith('/Organization') or ('/' + type).endsWith('/Practitioner') or ('/' + type).endsWith('/PractitionerRole')))) -> r.grantee = src "granteeFromSource";
  } "granteeFallback";
}