Skip to content

Instantly share code, notes, and snippets.

@andrewhampton
Last active February 3, 2026 16:00
Show Gist options
  • Select an option

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

Select an option

Save andrewhampton/97b77d76a7f7a254c83f54035ff1b74e to your computer and use it in GitHub Desktop.
Survey Architecture Rollout Plan (Option A: Pages)

Survey Architecture Implementation Plan (Option A: Pages)

Status: Draft

Author: Architecture Design
Date: 2026-02-03
Amp Thread: T-019c23fd-8a5b-718a-8fa0-8b5ef6a7c60b
Spec: 08_option_a_tech_spec.md
Evaluation: 09_option_a_implementation_evaluation.md


Executive Summary

This plan merges the strongest ideas from the three Option A branches:

  • survey-option-a: end-to-end participant UX, response-link routing, full page navigation
  • codex-app-option-a: systematic container refactors, Positionable extraction, duplication coverage
  • survey-architecture: faithful model design to the spec, visibility guardrails

We deliver the spec-aligned data model first, then ship builder and participant flows behind a feature flag, while keeping polls stable through dual-mode steps, safe migrations, and incremental refactors.


Design Pillars

  1. Container-first Steps: Step belongs to a polymorphic container (Poll or Survey::Page).
  2. Visibility-based navigation: Pages and steps are filtered by Condition using position order.
  3. Backwards compatibility: Poll flows remain intact during migration.
  4. Safe migrations: New columns start nullable, backfilled, and validated before constraints.
  5. Incremental refactors: Only touch query paths needed for surveys before full conversion.

Scope

Included

  • Survey + Page models and tables
  • Condition STI + visibility evaluation
  • Step polymorphic container migration + dual-mode model
  • Survey builder UX (pages/steps/conditions)
  • Participant survey routing and navigation
  • ResponseLink integration for surveys

Deferred

  • Branching transitions (v2)
  • Survey versioning
  • Go-back navigation + progress semantics
  • Broad analytics/reporting refactors

Phase 0: Foundation Models (New Tables Only)

Outcome: Create core tables and models without touching existing poll tables.

0.1 Migrations

# db/migrate/YYYYMMDDHHMMSS_create_conditions.rb
class CreateConditions < ActiveRecord::Migration[8.2]
  def change
    create_table :conditions do |t|
      t.string :type, null: false
      t.references :question, foreign_key: true, null: true
      t.jsonb :condition_value, default: {}
      t.timestamps

      t.index :type
    end
  end
end
# db/migrate/YYYYMMDDHHMMSS_create_surveys.rb
class CreateSurveys < ActiveRecord::Migration[8.2]
  def change
    create_table :surveys do |t|
      t.string :title, null: false
      t.bigint :start_page_id
      t.timestamps
    end
  end
end
# db/migrate/YYYYMMDDHHMMSS_create_survey_pages.rb
class CreateSurveyPages < ActiveRecord::Migration[8.2]
  def change
    create_table :survey_pages do |t|
      t.references :survey, null: false, foreign_key: true
      t.references :condition, foreign_key: true, null: true
      t.string :title
      t.integer :position, limit: 2, null: false
      t.timestamps

      t.index [:survey_id, :position]
    end

    add_foreign_key :surveys, :survey_pages, column: :start_page_id, validate: false
  end
end
# db/migrate/YYYYMMDDHHMMSS_validate_surveys_start_page_fk.rb
class ValidateSurveysStartPageFk < ActiveRecord::Migration[8.2]
  def change
    validate_foreign_key :surveys, :survey_pages
  end
end
# db/migrate/YYYYMMDDHHMMSS_create_contents.rb
class CreateContents < ActiveRecord::Migration[8.2]
  def change
    create_table :contents do |t|
      t.text :body
      t.timestamps
    end
  end
end
# db/migrate/YYYYMMDDHHMMSS_create_survey_journeys.rb
class CreateSurveyJourneys < ActiveRecord::Migration[8.2]
  def change
    create_enum :survey_journey_status, %w[in_progress completed abandoned]

    create_table :survey_journeys do |t|
      t.references :survey, null: false, foreign_key: true
      t.references :response_link, null: false, foreign_key: true
      t.references :respondent, foreign_key: { to_table: :users }, null: true
      t.references :current_page, foreign_key: { to_table: :survey_pages }, null: false
      t.enum :status, enum_type: :survey_journey_status, default: "in_progress", null: false
      t.string :anonymous_token
      t.datetime :completed_at
      t.datetime :abandoned_at
      t.timestamps

      t.index [:survey_id, :respondent_id], unique: true, where: "respondent_id IS NOT NULL"
      t.index [:survey_id, :anonymous_token], unique: true, where: "anonymous_token IS NOT NULL"
      t.index :status
    end
  end
end

0.2 Models

# app/models/condition.rb
class Condition < ApplicationRecord
  belongs_to :question, optional: true

  validates :type, presence: true

  def evaluate(journey)
    raise NotImplementedError, "#{self.class} must implement #evaluate"
  end

  protected

  def response_value(journey)
    return nil unless question

    journey.response_for(question.id)&.value
  end
end
# app/models/condition/always.rb
class Condition::Always < Condition
  validates :question, absence: true

  def evaluate(_journey)
    true
  end
end
# app/models/condition/response_equals.rb
class Condition::ResponseEquals < Condition
  validates :question, presence: true
  validate :condition_value_has_value_key

  def evaluate(journey)
    response_value(journey) == expected_value
  end

  def expected_value
    condition_value["value"]
  end

  private

  def condition_value_has_value_key
    return if condition_value.is_a?(Hash) && condition_value.key?("value")

    errors.add(:condition_value, "must contain 'value' key")
  end
end
# app/models/condition/response_in.rb
class Condition::ResponseIn < Condition
  validates :question, presence: true
  validate :condition_value_has_values_array

  def evaluate(journey)
    expected_values.include?(response_value(journey))
  end

  def expected_values
    condition_value["values"] || []
  end

  private

  def condition_value_has_values_array
    return if condition_value.is_a?(Hash) && condition_value["values"].is_a?(Array)

    errors.add(:condition_value, "must contain 'values' array")
  end
end
# app/models/survey.rb
class Survey < ApplicationRecord
  # TODO: Phase 1 - Add Survey::Duplicable when steps association exists
  include Auditable, Configurable, Resourceable, Survey::Titleable

  belongs_to :start_page, class_name: "Survey::Page", optional: true

  has_many :pages,
    -> { order(position: :asc) },
    class_name: "Survey::Page",
    inverse_of: :survey,
    dependent: :destroy

  # TODO: Phase 1 - Add when container_type/container_id columns exist on steps
  # has_many :steps, through: :pages
  # has_many :questions, through: :steps, source: :steppable, source_type: "Question"

  has_many :response_links, dependent: :destroy
  has_many :journeys, class_name: "Survey::Journey", dependent: :destroy

  validates :pages, presence: true

  delegate :organization_id, to: :resource

  before_validation :set_default_start_page, on: :create

  def start_page
    super || pages.ordered.first
  end

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

  def creator=(user)
    build_resource unless resource
    resource.creator = user
  end

  def incinerate! = destroy

  private

  def set_default_start_page
    self.start_page ||= pages.first
  end
end
# app/models/survey/page.rb
class Survey::Page < ApplicationRecord
  self.table_name = "survey_pages"

  belongs_to :survey, inverse_of: :pages
  belongs_to :condition, optional: true

  # TODO: Phase 1 - Add when container_type/container_id columns exist on steps
  # has_many :steps, -> { order(position: :asc) }, as: :container, dependent: :destroy
  # has_many :questions, through: :steps, source: :steppable, source_type: "Question"

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

  validates :position, presence: true, numericality: { only_integer: true, greater_than: 0 }

  before_validation :set_default_position, on: :create

  def visible_for?(journey)
    return true if condition.nil?

    condition.evaluate(journey)
  end

  private

  def set_default_position
    return if position.present?

    max_position = survey.pages.maximum(:position).to_i
    self.position = max_position + 1
  end
end
# app/models/content.rb
class Content < ApplicationRecord
  include Steppable

  def icon = "document_text"

  def duplicate_for(poll:, creator:)
    dup
  end
end

0.3 Journey Models

# app/models/survey/journey.rb
class Survey::Journey < ApplicationRecord
  include Survey::Journey::Navigable
  include Survey::Journey::Completable

  self.table_name = "survey_journeys"

  belongs_to :survey
  belongs_to :response_link
  belongs_to :respondent, class_name: "User", optional: true
  belongs_to :current_page, class_name: "Survey::Page"

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

  scope :active, -> { where(status: :in_progress) }

  validates :anonymous_token, presence: true, unless: :respondent_id?

  before_validation :generate_anonymous_token, on: :create, unless: :respondent_id?

  def response_for(question_id)
    # Placeholder: Will be implemented in Phase 2 when we integrate with Vote/Submission
    nil
  end

  private

  def generate_anonymous_token
    self.anonymous_token ||= SecureRandom.uuid
  end
end
# app/models/survey/journey/navigable.rb
module Survey::Journey::Navigable
  extend ActiveSupport::Concern

  def advance!
    raise InvalidTransition, "Cannot advance a #{status} journey" unless in_progress?

    next_page = find_next_visible_page

    if next_page.nil?
      complete!
    else
      update!(current_page: next_page)
    end
  end

  def can_advance?
    visible_steps_on_current_page.all? do |step|
      next true unless step.steppable.is_a?(Question)
      next true unless step.steppable.respond_to?(:required?) && step.steppable.required?

      response_for(step.steppable.id).present?
    end
  end

  def visible_steps_on_current_page
    current_page.steps.ordered.select { |step| step_visible?(step) }
  end

  class InvalidTransition < StandardError; end

  private

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

  def step_visible?(step)
    return true if step.condition.nil?

    step.condition.evaluate(self)
  end
end
# app/models/survey/journey/completable.rb
module Survey::Journey::Completable
  extend ActiveSupport::Concern

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

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

0.4 Tests

  • test/models/condition_test.rb + subtype tests
  • test/models/survey_test.rb
  • test/models/survey/page_test.rb
  • test/models/survey/journey_test.rb
  • test/models/content_test.rb

Phase 1: Polymorphic Step Containers (Backward Compatible)

Outcome: Steps support container_type/id while still supporting poll_id.

1.1 Safe Schema Changes

# db/migrate/YYYYMMDDHHMMSS_add_polymorphic_container_to_steps.rb
class AddPolymorphicContainerToSteps < ActiveRecord::Migration[8.2]
  disable_ddl_transaction!

  def change
    add_column :steps, :container_type, :string
    add_column :steps, :container_id, :bigint
    add_reference :steps, :condition, foreign_key: true, null: true

    add_index :steps, [:container_type, :container_id, :position],
      algorithm: :concurrently,
      name: "index_steps_on_container_and_position"
  end
end
# db/migrate/YYYYMMDDHHMMSS_backfill_step_containers.rb
class BackfillStepContainers < ActiveRecord::Migration[8.2]
  disable_ddl_transaction!

  def up
    Step.unscoped.in_batches(of: 1000) do |batch|
      batch.update_all(container_type: "Poll", container_id: Arel.sql("poll_id"))
    end
  end

  def down
    Step.unscoped.update_all(container_type: nil, container_id: nil)
  end
end

1.2 Step Model (Dual-Mode)

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

  belongs_to :poll, inverse_of: :steps, optional: true
  belongs_to :container, polymorphic: true, optional: true
  belongs_to :condition, optional: true

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

  before_validation :sync_container_from_poll, if: -> { poll_id.present? && container_id.nil? }

  def visible_for?(journey)
    condition.nil? || condition.applies_to?(journey)
  end

  private

  def sync_container_from_poll
    self.container_type = "Poll"
    self.container_id = poll_id
  end
end

1.3 Positionable Extraction

  • Extract Positionable from Step::Positionable to app/models/concerns/positionable.rb.
  • Update Step::Positionable to include Positionable.
  • Use Positionable in Survey::Page.

1.4 Targeted Refactors

  • Survey builder uses Step.where(container: page).
  • Journey navigation uses page.steps.ordered.
  • Duplication logic for surveys copies pages, then steps with container_type: "Survey::Page".
  • Avoid touching poll analytics/reporting paths.

1.5 Tests

  • Step container backfill
  • Shared Positionable ordering for Survey::Page and Step
  • Survey duplication + container integrity

Phase 2: Resource + ResponseLink Integration

Outcome: Surveys can be created as resources and accessed via response links.

2.1 Schema Changes

# db/migrate/YYYYMMDDHHMMSS_add_survey_to_response_links.rb
class AddSurveyToResponseLinks < ActiveRecord::Migration[8.2]
  def change
    add_reference :response_links, :survey, foreign_key: true, null: true
    change_column_null :response_links, :poll_id, true

    add_check_constraint :response_links,
      "(poll_id IS NOT NULL AND survey_id IS NULL) OR (poll_id IS NULL AND survey_id IS NOT NULL)",
      name: "response_links_exactly_one_parent",
      validate: false
  end
end
# db/migrate/YYYYMMDDHHMMSS_validate_response_links_constraint.rb
class ValidateResponseLinksConstraint < ActiveRecord::Migration[8.2]
  def change
    validate_check_constraint :response_links, name: "response_links_exactly_one_parent"
  end
end

2.2 Model Changes

# app/models/resource.rb
class Resource < ApplicationRecord
  delegated_type :resourceable, types: %w[Folder Poll Survey]
end
# app/models/response_link.rb
class ResponseLink < ApplicationRecord
  belongs_to :survey, optional: true
end

2.3 Controllers and Routes

  • SurveysController for CRUD (behind feature flag)
  • Survey::PagesController for page CRUD
  • ResponseLinksController allows survey response link creation

2.4 Tests

  • ResponseLink constraint validation tests
  • Survey creation via Resource

Phase 3: Visibility Logic + Guardrails

Outcome: Conditions are enforced and safe across pages/steps.

3.1 Visibility Evaluation

  • Condition#applies_to?(journey) for each STI type
  • Survey::Page#visible_for?
  • Step#visible_for?

3.2 Condition Ordering Validations

  • Survey::Page validation: condition.question_id must refer to a question on a lower position page
  • Step validation: condition.question_id must refer to a question earlier in page position or earlier page

3.3 Tests

  • Page/step visibility rules
  • Invalid condition rejection

Phase 4: Survey Builder (Pages + Steps)

Outcome: Builder supports multi-page surveys with conditions.

4.1 Controllers + Views

  • SurveysController for create/edit
  • Survey::PagesController for add/remove/reorder
  • Steps creation uses current page container
  • Condition UI for page and step visibility

4.2 Positionable UI

  • Drag/drop ordering for pages and steps
  • Persist via Positionable concerns

4.3 Tests

  • Controller tests for page CRUD and ordering
  • System test: create survey → add page → add step → set condition

Phase 5: Participant Experience (End-to-End)

Outcome: Survey link renders pages/steps with visibility.

5.1 Routing

  • /surveys/:survey_id/respond/:response_link_id (pattern parallel to polls)
  • Use response link to create or resume journey

5.2 Rendering

  • Survey renderer page view
  • Steps list per page using visible_steps_on_current_page
  • Completion page

5.3 Journey Flow

  • Create journey at first visible page
  • advance! moves to next visible page or completes

5.4 Tests

  • Integration test: response link → journey → completion
  • System test: survey with visibility skips hidden pages

Phase 6: Incremental Container Refactors

Outcome: Containerized steps are first-class without risky global changes.

6.1 Targeted Updates

  • Builder queries use Step.where(container: container)
  • Duplication uses container-aware step copying
  • Survey exports/reporting use container-based steps

6.2 Deferred Refactors

  • Poll-only analytics/reporting queries remain poll-specific
  • Future work list for container migration

6.3 Tests

  • Poll regression tests for builder

Phase 7: Constraints + Cleanup

Outcome: Enforce container constraints and remove poll_id once adoption is complete.

7.1 Constraints

# db/migrate/YYYYMMDDHHMMSS_add_step_container_constraints.rb
class AddStepContainerConstraints < ActiveRecord::Migration[8.2]
  def change
    add_check_constraint :steps,
      "container_type IS NOT NULL AND container_id IS NOT NULL",
      name: "steps_container_not_null",
      validate: false
  end
end
# db/migrate/YYYYMMDDHHMMSS_validate_step_container_constraints.rb
class ValidateStepContainerConstraints < ActiveRecord::Migration[8.2]
  def change
    validate_check_constraint :steps, name: "steps_container_not_null"
  end
end

7.2 Cleanup

# db/migrate/YYYYMMDDHHMMSS_remove_poll_id_from_steps.rb
class RemovePollIdFromSteps < ActiveRecord::Migration[8.2]
  def change
    remove_foreign_key :steps, :polls
    remove_index :steps, [:poll_id, :position]
    remove_column :steps, :poll_id, :bigint
  end
end

Feature Flag Rollout

# config/initializers/feature_flags.rb
Flipper.register(:surveys_beta) do |actor, _context|
  actor.respond_to?(:feature_flags) && actor.feature_flags[:surveys_beta]
end
  • Internal rollout → beta orgs → GA

Testing Strategy

  • Unit: Condition STI, Survey/Page/Journey, Step container
  • Integration: ResponseLink, Survey builder, Journey navigation
  • System: participant flow + builder multi-page flow

Open Questions

  1. Anonymity in Survey::Journey#response_for (avoid deanonymization).
  2. Auto-create response links on publish vs manual creation.
  3. Progress indicator semantics with visibility.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment