Surveys & questionnaires as a Caliper-native product feature
Status: accepted
Date: 2026-06-26
Relates to: ADR-0041 (Caliper rail / shared telemetry projection seam), ADR-0042 (pseudonymous
emission governance), ADR-0021/ADR-0025 (QTI assessments — distinct, graded),
caliper:1.2:conf:vocabulary/CAL-VOCAB-2.
Context
Section titled “Context”SurveyProfile is the second metric profile parked in DEFERRED_PROFILES (action-vocabulary.ts): no
emergent surface emits it. emergent has graded assessment (QTI, ADR-0021/0025) but no non-graded
survey/questionnaire/poll/Likert capability. That is a real product gap the standard surfaced
([[feedback_standards_driven_product_discovery]]) — course-feedback surveys, polls, and Likert
questionnaires are core LMS surfaces, and they are deliberately not assessments: no score, no
gradebook, and commonly anonymous.
Caliper’s SurveyProfile is unusually structured — four event types over five entity types — so the product model is shaped to mirror it faithfully rather than retrofit a generic form.
Decisions
Section titled “Decisions”-
A faithful Caliper-shaped domain model. Six tables map 1:1 onto the SurveyProfile entities:
survey— the CaliperSurveycontainer (offering-placed; carries theanonymousflag and open/close lifecycle).survey_invitation— aSurveyInvitationto a person; drivesSurveyInvitationEvent(Sent/Accepted/Declined) andSurveyEvent(OptedIn/OptedOut consent) from one row.questionnaire— the CaliperQuestionnaire(the form; belongs to a survey). A survey has one or more; the common case is one.questionnaire_item— a CaliperQuestionnaireItem(a question), a derived, stable index over the SurveyJS definition (one row per question):question_code(the SurveyJS questionname, the response join key) +question_type(the SurveyJS type verbatim, free text). It is regenerated from the definition on save; the telemetry reader uses only its id + the response status, so it is rendering-agnostic.questionnaire_response— a response attempt; projectsQuestionnaireEvent(Started/Submitted), itsAttemptthe Calipergenerated.questionnaire_item_response— one answer; projectsQuestionnaireItemEvent(Started/Skipped/Completed), itsResponsethegenerated.
All four SurveyProfile event types are emitted this session.
-
Anonymous-capable, with anonymity enforced at write time. A
survey.anonymoussurvey stores no person link on its responses (questionnaire_response.person_idis NULL by construction, withanonymous = truecopied onto the row) — the platform itself cannot re-attribute them. Caliper requires aPersonactor, so the reader emits a pseudonym = the response id (a random uuid, unlinkable to any person), preserving per-response event correlation (Started→Submitted) without identity. Identified surveys linkperson_idand emit pseudonymously (account-IFI, ADR-0042) like the rest of the rail. The reader distinguishes the two NULL-person cases:anonymous=false+ NULL means erasure-severed → skip;anonymous=truemeans anonymous → emit with the response-id pseudonym. -
Rendering via the SurveyJS Form Library (MIT), with the SurveyJS model as the definition source of truth (revised — see Amendment).
questionnaire.definitionis a SurveyJS model; the learner surface renders it withsurvey-core+survey-react-ui, persisting answers to our canonicalquestionnaire_response/questionnaire_item_responsetables (anonymity + Caliper unchanged). The research-grade question/logic set (matrix, ranking, rating/NPS, dynamic panels, conditional/branching logic, piping, randomization, multi-page, WCAG a11y) is needed near-term and comes for free; the commercial Survey Creator (visual builder), PDF generator, and Dashboard are deferred — surveys are authored via the SurveyJS model (seed/JSON now; a builder later). This is non-graded — distinct from the QTI engine, which stays the path for scored assessment. -
Responses are submitted via a server endpoint, not the offline sync rail. Unlike reading/media progress (incremental, always person-owned → sync rail), a survey response is a one-shot submission that must support anonymity. A
POST /workflow/surveys/:id/responsesendpoint writesquestionnaire_response+questionnaire_item_responseserver-side (under an authenticated-insert policy,person_idNULL for anonymous), keeping anonymity and RLS clean. The tables still carry the audit columns the rail’s(updated_at_us, id)keyset readers need. (Offline survey-taking could be a later enhancement.) -
Consent (SurveyEvent) is modelled on the invitation.
SurveyEventOptedIn/OptedOut represents a person consenting to (identified) participation in the survey program; it is carried asconsent/consent_decided_atonsurvey_invitation, distinct from the invitation’s own Sent/Accepted/Declined lifecycle. One invitation row can therefore project up to three facts (an invitation sent, accepted/declined, and an opt-in/opt-out) — each a genuinely distinct Caliper event with its own object (SurveyInvitation vs Survey).
Amendment (2026-06-26): SurveyJS Form Library for rendering
Section titled “Amendment (2026-06-26): SurveyJS Form Library for rendering”The original decision 3 chose Mantine-native rendering (one model, no dependency) on the assumption
that surveys were conformance-level (basic single/multi/Likert/open/date). Product then confirmed
research-level surveys are a near-term priority and matrix questions (and the sophistication they
imply — ranking, dynamic panels, conditional/branching logic, piping, randomization) are a hard
requirement. Hand-building that in Mantine is effectively re-implementing a survey engine, so we
adopt the SurveyJS Form Library (survey-core + survey-react-ui, MIT) — the only mature
embeddable React survey engine; LimeSurvey/REDCap are research-grade but separate apps (wrong shape
for embedded + our data + Caliper capture), and Formbricks is AGPL/platform-shaped.
Impact: questionnaire.definition jsonb (the SurveyJS model) becomes the source of truth, and
questionnaire_item becomes a derived index (question_code + free-text question_type). The
telemetry rail is unchanged — the reader projects response rows and never reads the question type,
so emission is rendering-agnostic. The paid Creator/PDF/Dashboard stay deferred. (Pre-launch, no data:
the survey schema was simply regenerated.)
Conformance impact
Section titled “Conformance impact”SurveyProfile moves from DEFERRED_PROFILES to emitted: the Sensor declares + emits all four
SurveyProfile event types, covered in the interop conformance lane. As with Media this does not flip
CAL-VOCAB-2 (which also needs the AggregateMeasure metric + Active/Inactive status follow-up).
CAL-VOCAB-1 stays green: every new action term is in conform-ed’s CALIPER_ACTIONS and permitted for
its event type.
Operator follow-ups
Section titled “Operator follow-ups”- One migration batch (the six survey tables) — Anton’s domain ([[feedback_db_migrations_anton_domain]]); regenerated by the agent, applied by Anton.
- The emission worker host stays held (ADR-0041 consequences) — stood up once after Media/Survey + the AggregateMeasure/status follow-up to flip CAL-SND-1 + CAL-VOCAB-2 together.
Addendum (2026-06-26): teacher authoring built (JSON intake)
Section titled “Addendum (2026-06-26): teacher authoring built (JSON intake)”The deferred teacher authoring surface is now built as a JSON-intake surface (the commercial visual
SurveyJS Creator stays deferred — teachers build the model for free on surveyjs.io). From the
offering workspace’s Surveys tab a teacher uploads or pastes the SurveyJS model JSON, sets title /
anonymity / status, and creates the survey; the server stores the definition verbatim on the
questionnaire and derives the canonical questionnaire_item index via deriveQuestionnaireItems (a
rendering-agnostic walk: flattens static panels, treats matrix/paneldynamic as single items, skips
html/image/expression). The tab also lists surveys (with question/response counts) and sets
lifecycle status (draft/open/closed). API: survey-authoring service +
/workflow/teacher/offerings/:offeringId/surveys routes, claim-aware. Still out of scope: the in-app
visual builder and offering-contents discovery wiring (learners reach a survey via its delivery route).