Skip to content

Instantly share code, notes, and snippets.

@andrewhampton
Last active February 2, 2026 20:08
Show Gist options
  • Select an option

  • Save andrewhampton/e0b43a9f06e5fb2afdd25a42bf6344bc to your computer and use it in GitHub Desktop.

Select an option

Save andrewhampton/e0b43a9f06e5fb2afdd25a42bf6344bc to your computer and use it in GitHub Desktop.
Survey Architecture Tech Spec - Option A (Pages)

Survey Architecture Technical Specification

Status: Draft

Author: Architecture Design
Date: 2026-02-02
Amp Thread: T-019c1fb2-3b97-73e9-9463-f4eb68f1e8b8


Overview

This specification defines the implementation of surveys in Artemis using Option A: Survey with Pages. Survey becomes a sibling resourceable to Poll, with pages containing sequential steps. V1 uses visibility-based page display; branching logic (edge-based navigation) will be added in v2.

Goals

  1. Reuse existing Step model — polymorphic container enables both Poll and Page to contain steps
  2. Support page visibility logic — pages can be conditionally shown/hidden based on prior responses
  3. Follow established patterns — 37signals delegated types, Fizzy concern organization, existing Artemis conventions
  4. Design for future branching — visibility conditions on pages are compatible with later edge-based navigation

Non-Goals (v1)

  • Branching logic via transitions (v2 — visibility-first approach)
  • Go-back navigation (needs product input)
  • Survey versioning/immutability (defer to v2)
  • Nested/reusable surveys (Option C, future consideration)
  • Survey templates or library

Decisions

Question Decision
Response data model Reuse existing Vote/Submission models
Question reuse Questions are never shared across pages or polls
Anonymity Question-level concern only, not on Survey

V1 vs V2: Visibility vs Branching

V1: Visibility-Based Navigation

Pages are traversed in position order. Each page has an optional visibility_condition that determines if it should be shown.

Page 1 (position: 1) → always visible
Page 2 (position: 2) → visible if Q1 = "student"
Page 3 (position: 3) → visible if Q1 = "professional"
Page 4 (position: 4) → always visible

Traversal: evaluate pages in position order, skip those where visibility condition fails.

V2: Branching (Future)

Pages form a DAG via Survey::PageTransition edges. Position becomes "builder sidebar order" only.

Why this is compatible: Visibility is a node property ("should this page show?"), branching is edge-based ("where to go next?"). When we add branching:

  • Transitions determine the path through the DAG
  • Visibility conditions still apply as an additional filter on each page
  • Both can coexist without conflict

Model Architecture

Class Diagram

classDiagram
    class Resource {
        +id
        +organization_id
        +creator_id
        +parent_id
        +resourceable_type
        +resourceable_id
        +discarded_at
    }
    class Survey {
        +id
        +title
        +has_many pages
        +has_many response_links
    }
    class `Survey::Page` {
        +id
        +survey_id
        +position
        +title
        +visibility_condition
        +has_many steps
    }
    class Step {
        +id
        +container_type
        +container_id
        +position
        +steppable_type
        +steppable_id
        +theme_id
        +creator_id
    }
    class Question {
        +id
        +title
        +anonymity_mode
    }
    class TextBlock {
        +id
        +content
    }
    
    Resource "1" --> "1" Survey : resourceable
    Survey "1" --> "*" `Survey::Page`
    `Survey::Page` "1" --> "*" Step : container
    Step "1" --> "1" Question : steppable
    Step "1" --> "1" TextBlock : steppable
Loading

Model Hierarchy

Organization
└── Resource (delegated_type: Folder | Poll | Survey)
    └── Survey
        └── Survey::Page (ordered by position, optional visibility_condition)
            └── Step (sequential within page, ordered by position)
                └── Question | TextBlock (steppables)

New Models

Survey

Primary container for survey content. Sibling to Poll under Resource.

# app/models/survey.rb
class Survey < ApplicationRecord
  include Auditable, Configurable, Duplicable, Resourceable, Titleable

  has_many :pages,
    -> { ordered },
    class_name: "Survey::Page",
    dependent: :destroy,
    inverse_of: :survey

  has_many :steps,
    through: :pages

  has_many :questions,
    through: :steps,
    source: :steppable,
    source_type: "Question"

  has_many :response_links, dependent: :destroy

  validates :pages, presence: true

  delegate :organization_id, to: :resource

  def start_page
    pages.ordered.first
  end

  def published?
    response_links.standalone.exists?(&:active?)
  end
end

Concerns:

  • Auditable — activity logging
  • Configurable — settings management
  • Duplicable — copy/duplicate functionality
  • Resourceable — integration with Resource hierarchy
  • Titleable — title handling with defaults

Survey::Page

Container for steps within a survey. Traversed in position order with optional visibility filtering.

# app/models/survey/page.rb
class Survey::Page < ApplicationRecord
  include Positionable
  include Survey::Page::Visibility

  belongs_to :survey, inverse_of: :pages

  has_many :steps,
    -> { ordered },
    as: :container,
    dependent: :destroy

  has_many :questions,
    through: :steps,
    source: :steppable,
    source_type: "Question"

  scope :ordered, -> { order(position: :asc, id: :asc) }

  validates :survey, presence: true

  delegate :organization_id, to: :survey

  # Override to use survey as position scope
  def position_scope
    survey.pages
  end

  def title
    super.presence || "Page #{position}"
  end

  def first?
    position == 1
  end
end

Survey::Page::Visibility

Visibility condition evaluation for pages.

# app/models/survey/page/visibility.rb
module Survey::Page::Visibility
  extend ActiveSupport::Concern

  CONDITION_TYPES = %w[always response_equals response_in].freeze

  included do
    # visibility_condition: jsonb column
    # Format: { "type": "response_equals", "question_id": 1, "value": "yes" }
    # Or: { "type": "always" } (default, can be null)
  end

  def visible_for?(submission)
    return true if visibility_condition.blank?
    return true if visibility_condition["type"] == "always"

    case visibility_condition["type"]
    when "response_equals"
      evaluate_equals(submission)
    when "response_in"
      evaluate_in(submission)
    else
      true
    end
  end

  private

  def evaluate_equals(submission)
    config = visibility_condition
    return true unless config["question_id"] && config["value"]

    response = submission.response_for(config["question_id"])
    response&.value == config["value"]
  end

  def evaluate_in(submission)
    config = visibility_condition
    return true unless config["question_id"] && config["values"].is_a?(Array)

    response = submission.response_for(config["question_id"])
    config["values"].include?(response&.value)
  end
end

Positionable (Shared Concern)

Generic position management concern, reusable across models.

# app/models/concerns/positionable.rb
module Positionable
  extend ActiveSupport::Concern

  included do
    before_create :set_default_position
    around_destroy :reposition_following_records
  end

  # Override in including class to define the scope for positioning
  # @return [ActiveRecord::Relation] the collection to position within
  def position_scope
    raise NotImplementedError, "#{self.class} must implement #position_scope"
  end

  def previous_by_position
    position_scope.reorder(position: :desc).where("position < ?", position).first
  end

  def next_by_position
    position_scope.where("position > ?", position).first
  end

  def move_to(new_position)
    return if new_position == position

    transaction do
      if new_position > position
        # Moving down: shift items between old and new position up
        position_scope
          .where("position > ? AND position <= ?", position, new_position)
          .update_all("position = position - 1")
      else
        # Moving up: shift items between new and old position down
        position_scope
          .where("position >= ? AND position < ?", new_position, position)
          .update_all("position = position + 1")
      end

      update_column(:position, new_position)
    end
  end

  private

  def set_default_position
    return if position.present?

    max_position = position_scope.maximum(:position).to_i
    self.position = max_position + 1
  end

  def reposition_following_records
    # Track destroying records to prevent re-entry
    destroying_key = :"#{self.class.name.underscore.pluralize}_destroying"
    records_destroying = Thread.current[destroying_key] ||= Set.new
    return yield if records_destroying.include?(id)

    records_destroying.add(id)
    old_position = position
    old_scope = position_scope

    begin
      yield

      # Reposition following records after destroy
      return if old_scope.nil?

      old_scope
        .where("position > ?", old_position)
        .update_all("position = position - 1")
    ensure
      records_destroying.delete(id)
    end
  end
end

Step::Positionable (Updated)

Refactored to use shared Positionable concern.

# app/models/step/positionable.rb
module Step::Positionable
  extend ActiveSupport::Concern
  include Positionable

  # Step positions within its container (Poll or Survey::Page)
  def position_scope
    container.steps
  end

  # Legacy compatibility methods
  def previous
    previous_by_position
  end

  def previous!
    previous or raise ActiveRecord::RecordNotFound
  end

  def next
    next_by_position
  end

  def next!
    self.next or raise ActiveRecord::RecordNotFound
  end
end

TextBlock

New steppable type for instructional/informational content.

# app/models/text_block.rb
class TextBlock < ApplicationRecord
  include Steppable

  has_rich_text :content

  validates :content, presence: true

  def activatable? = false
  def icon = "document_text"

  def duplicate_for(container:, creator:)
    dup.tap do |copy|
      copy.content = content.body.to_s
    end
  end
end

Modified Models

Resource

Add Survey to delegated types.

# app/models/resource.rb (modification)
delegated_type :resourceable, types: %w[Folder Poll Survey]

Step

Change from belongs_to :poll to polymorphic belongs_to :container.

# app/models/step.rb (modification)
class Step < ApplicationRecord
  include Step::Positionable

  # CHANGE: polymorphic container instead of direct poll association
  belongs_to :container, polymorphic: true, inverse_of: :steps
  belongs_to :theme, default: -> { default_theme }
  belongs_to :creator, class_name: "User", optional: true, default: -> { Current.user }

  delegated_type :steppable, types: %w[Question TextBlock]

  scope :ordered, -> { order(position: :asc) }

  delegate :title, :icon, to: :steppable, allow_nil: true

  # Convenience methods for container types
  def poll
    container if container.is_a?(Poll)
  end

  def page
    container if container.is_a?(Survey::Page)
  end

  def survey
    page&.survey
  end

  def organization_id
    case container
    when Poll then container.organization_id
    when Survey::Page then container.organization_id
    end
  end

  private

  def default_theme
    existing_theme || Configuration::Theme.for(Current.organization, Current.user) || Theme.default
  end

  def existing_theme
    case container
    when Poll
      container.steps.excluding(self).ordered.last&.theme
    when Survey::Page
      container.steps.excluding(self).ordered.last&.theme ||
        container.survey.pages.flat_map(&:steps).last&.theme
    end
  end
end

Steppable Concern

Update delegations for polymorphic container.

# app/models/concerns/steppable.rb (modification)
module Steppable
  extend ActiveSupport::Concern

  included do
    has_one :step, as: :steppable, inverse_of: :steppable, touch: true

    delegate :container, :position, :theme, :creator, :creator_id, to: :step

    before_destroy :destroy_step_unless_already_destroying
  end

  # Convenience accessors
  def poll
    step&.poll
  end

  def poll_id
    poll&.id
  end

  def page
    step&.page
  end

  def survey
    step&.survey
  end

  def organization_id
    step&.organization_id
  end

  private

  def destroy_step_unless_already_destroying
    return if step.nil? || step.destroyed? || step.marked_for_destruction?

    step.destroy
  end

  def activatable? = false
  def icon = "question_circle"

  def duplicate_for(container:, creator:)
    raise NotImplementedError, "#{self.class} must implement #duplicate_for"
  end
end

Database Schema

New Tables

# db/migrate/YYYYMMDDHHMMSS_create_surveys.rb
class CreateSurveys < ActiveRecord::Migration[7.2]
  def change
    create_table :surveys do |t|
      t.string :title, null: false

      t.timestamps
    end
  end
end
# db/migrate/YYYYMMDDHHMMSS_create_survey_pages.rb
class CreateSurveyPages < ActiveRecord::Migration[7.2]
  def change
    create_table :survey_pages do |t|
      t.references :survey, null: false, foreign_key: true, index: true
      t.integer :position, limit: 2, null: false
      t.string :title
      t.jsonb :visibility_condition

      t.timestamps

      t.index [:survey_id, :position]
    end
  end
end
# db/migrate/YYYYMMDDHHMMSS_create_text_blocks.rb
class CreateTextBlocks < ActiveRecord::Migration[7.2]
  def change
    create_table :text_blocks do |t|
      t.timestamps
    end

    # ActionText handles rich content storage
  end
end

Schema Modifications

# db/migrate/YYYYMMDDHHMMSS_add_polymorphic_container_to_steps.rb
class AddPolymorphicContainerToSteps < ActiveRecord::Migration[7.2]
  def up
    # Add polymorphic columns
    add_column :steps, :container_type, :string
    add_column :steps, :container_id, :bigint

    # Migrate existing data: Poll -> container
    execute <<~SQL
      UPDATE steps 
      SET container_type = 'Poll', 
          container_id = poll_id
    SQL

    # Make columns non-null after migration
    change_column_null :steps, :container_type, false
    change_column_null :steps, :container_id, false

    # Add indexes
    add_index :steps, [:container_type, :container_id, :position]
  end

  def down
    remove_index :steps, [:container_type, :container_id, :position]
    remove_column :steps, :container_type
    remove_column :steps, :container_id
  end
end
# db/migrate/YYYYMMDDHHMMSS_add_survey_to_response_links.rb
class AddSurveyToResponseLinks < ActiveRecord::Migration[7.2]
  def change
    # Make poll_id nullable for survey response links
    change_column_null :response_links, :poll_id, true
    
    # Add survey reference
    add_reference :response_links, :survey, foreign_key: true, index: true

    # Add check constraint: must have poll_id OR survey_id
    add_check_constraint :response_links, 
      "(poll_id IS NOT NULL) OR (survey_id IS NOT NULL)",
      name: "response_links_requires_container"
  end
end

Survey Submission Model

Container for one respondent's complete response set. Links to existing Vote/Submission models.

# app/models/survey_submission.rb
class SurveySubmission < ApplicationRecord
  include SurveySubmission::Navigable
  include SurveySubmission::Completable

  belongs_to :survey
  belongs_to :response_link
  belongs_to :respondent, class_name: "User", optional: true

  # Links to existing response models
  has_many :votes, through: :questions
  has_many :submissions, through: :questions

  enum :status, 
    %i[in_progress completed abandoned].index_by(&:itself),
    validate: true,
    default: :in_progress

  scope :completed, -> { where(status: :completed) }
  scope :in_progress, -> { where(status: :in_progress) }
  scope :chronologically, -> { order(created_at: :asc, id: :asc) }

  attribute :current_page_id, :integer

  validates :response_link, presence: true

  def current_page
    survey.pages.find_by(id: current_page_id) || survey.start_page
  end

  def response_for(question_id)
    # Delegate to existing Vote/Submission lookup
    question = Question.find_by(id: question_id)
    return nil unless question

    question.responses.find_by(presenter: response_link.publisher)
  end

  private

  def questions
    survey.questions
  end
end

SurveySubmission::Navigable

# app/models/survey_submission/navigable.rb
module SurveySubmission::Navigable
  extend ActiveSupport::Concern

  def advance!
    next_page = find_next_visible_page
    
    if next_page.nil?
      complete!
    else
      update!(current_page_id: next_page.id)
    end
  end

  def can_advance?
    current_page.steps.all? do |step|
      next true unless step.steppable.is_a?(Question)
      next true unless step.steppable.required?
      
      response_for(step.steppable.id).present?
    end
  end

  private

  def find_next_visible_page
    survey.pages
      .ordered
      .where("position > ?", current_page.position)
      .find { |page| page.visible_for?(self) }
  end
end

SurveySubmission::Completable

# app/models/survey_submission/completable.rb
module SurveySubmission::Completable
  extend ActiveSupport::Concern

  def complete!
    update!(status: :completed, completed_at: Time.current)
  end

  def abandon!
    update!(status: :abandoned, abandoned_at: Time.current)
  end

  def completion_percentage
    return 100.0 if completed?
    
    visible_questions = visible_question_count
    return 0.0 if visible_questions.zero?

    answered = response_count
    (answered.to_f / visible_questions * 100).round(1)
  end

  private

  def visible_question_count
    survey.pages
      .select { |page| page.visible_for?(self) }
      .sum { |page| page.questions.count }
  end

  def response_count
    survey.questions.count { |q| response_for(q.id).present? }
  end
end

File Structure

app/models/
├── concerns/
│   ├── positionable.rb (NEW - shared)
│   └── steppable.rb (modified)
├── survey.rb (NEW)
├── survey/
│   ├── page.rb (NEW)
│   └── page/
│       └── visibility.rb (NEW)
├── survey_submission.rb (NEW)
├── survey_submission/
│   ├── navigable.rb (NEW)
│   └── completable.rb (NEW)
├── text_block.rb (NEW)
├── step.rb (modified)
├── step/
│   └── positionable.rb (modified - uses shared Positionable)
└── resource.rb (modified)

db/migrate/
├── YYYYMMDDHHMMSS_create_surveys.rb
├── YYYYMMDDHHMMSS_create_survey_pages.rb
├── YYYYMMDDHHMMSS_create_text_blocks.rb
├── YYYYMMDDHHMMSS_add_polymorphic_container_to_steps.rb
├── YYYYMMDDHHMMSS_create_survey_submissions.rb
└── YYYYMMDDHHMMSS_add_survey_to_response_links.rb

Visibility Condition Types (v1)

Type Description visibility_condition Example
always Always visible (default) null or {"type": "always"}
response_equals Visible if response matches value {"type": "response_equals", "question_id": 1, "value": "yes"}
response_in Visible if response is one of values {"type": "response_in", "question_id": 1, "values": ["a", "b"]}

Future considerations (v2+):

  • response_contains — text contains substring
  • response_gt / response_lt — numeric comparisons
  • participant_property — condition on user attributes

Example: Conditional Survey

Page 1 (position: 1): "Are you a student?" — always visible
Page 2 (position: 2): Student questions — visible if Q1 = "yes"
Page 3 (position: 3): Professional questions — visible if Q1 = "no"  
Page 4 (position: 4): "Any feedback?" — always visible

Database Records

survey_pages:

id survey_id position title visibility_condition
1 100 1 "Intro" null
2 100 2 "Student Path" {"type":"response_equals","question_id":1,"value":"yes"}
3 100 3 "Professional Path" {"type":"response_equals","question_id":1,"value":"no"}
4 100 4 "Feedback" null

steps:

id container_type container_id position steppable_type steppable_id
1 Survey::Page 1 1 Question 1
2 Survey::Page 2 1 Question 2
3 Survey::Page 2 2 Question 3
4 Survey::Page 3 1 Question 4
5 Survey::Page 3 2 Question 5
6 Survey::Page 4 1 Question 6

Traversal Example

If respondent answers Q1 = "yes":

  1. Page 1 (visible) → show
  2. Page 2 (visible, condition matches) → show
  3. Page 3 (not visible, condition fails) → skip
  4. Page 4 (visible) → show
  5. No more pages → complete

Open Questions

For Product/Design

  1. Go back navigation — Can users go back? Does it clear responses? Show warning?
  2. Progress indicator — Show percentage? Page numbers? Neither?
  3. Partial save — Auto-save on page advance? Manual save button?
  4. Resume later — Allow anonymous resume via token/cookie?

For Engineering

  1. Editing while live — Block edits? Allow non-breaking edits? Versioning?

Implementation Phases

Phase 1: Core Models

  • Shared Positionable concern
  • Survey model with Resource integration
  • Survey::Page with visibility conditions
  • TextBlock steppable type
  • Step polymorphic container migration
  • Database migrations

Phase 2: Navigation & Submission

  • SurveySubmission model
  • Visibility-based navigation
  • ResponseLink integration

Phase 3: Builder UI

  • Page management (add, remove, reorder)
  • Step management within pages
  • Visibility condition configuration UI

Phase 4: Participant Experience

  • Survey renderer
  • Navigation controls
  • Completion tracking
  • Thank you / completion page

Phase 5 (v2): Branching Logic

  • Survey::PageTransition model
  • Edge-based navigation
  • Graph validation (cycles, dead ends, unreachable nodes)
  • Transition configuration UI

References

Path Description
tmp/survey_architecture/01_artemis_current_architecture.md Existing delegated type patterns
tmp/survey_architecture/04_37signals_delegated_types.md 37signals pattern guidance
tmp/survey_architecture/07_container_structure_options.md Options comparison (A/B/C)
app/models/step.rb Current Step implementation
app/models/step/positionable.rb Position management pattern
app/models/concerns/steppable.rb Steppable concern
app/models/response_link.rb Async distribution mechanism
/Users/ah/tmp/fizzy/app/models/card.rb Fizzy concern-heavy model pattern
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment