Skip to content

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.

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.

  1. A faithful Caliper-shaped domain model. Six tables map 1:1 onto the SurveyProfile entities:

    • survey — the Caliper Survey container (offering-placed; carries the anonymous flag and open/close lifecycle).
    • survey_invitation — a SurveyInvitation to a person; drives SurveyInvitationEvent (Sent/Accepted/Declined) and SurveyEvent (OptedIn/OptedOut consent) from one row.
    • questionnaire — the Caliper Questionnaire (the form; belongs to a survey). A survey has one or more; the common case is one.
    • questionnaire_item — a Caliper QuestionnaireItem (a question), a derived, stable index over the SurveyJS definition (one row per question): question_code (the SurveyJS question name, 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; projects QuestionnaireEvent (Started/Submitted), its Attempt the Caliper generated.
    • questionnaire_item_response — one answer; projects QuestionnaireItemEvent (Started/Skipped/Completed), its Response the generated.

    All four SurveyProfile event types are emitted this session.

  2. Anonymous-capable, with anonymity enforced at write time. A survey.anonymous survey stores no person link on its responses (questionnaire_response.person_id is NULL by construction, with anonymous = true copied onto the row) — the platform itself cannot re-attribute them. Caliper requires a Person actor, 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 link person_id and 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=true means anonymous → emit with the response-id pseudonym.

  3. Rendering via the SurveyJS Form Library (MIT), with the SurveyJS model as the definition source of truth (revised — see Amendment). questionnaire.definition is a SurveyJS model; the learner surface renders it with survey-core + survey-react-ui, persisting answers to our canonical questionnaire_response / questionnaire_item_response tables (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.

  4. 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/responses endpoint writes questionnaire_response + questionnaire_item_response server-side (under an authenticated-insert policy, person_id NULL 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.)

  5. Consent (SurveyEvent) is modelled on the invitation. SurveyEvent OptedIn/OptedOut represents a person consenting to (identified) participation in the survey program; it is carried as consent/consent_decided_at on survey_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.)

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.

  • 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).