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
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.
- Container-first Steps: Step belongs to a polymorphic container (Poll or Survey::Page).
- Visibility-based navigation: Pages and steps are filtered by
Conditionusing position order. - Backwards compatibility: Poll flows remain intact during migration.
- Safe migrations: New columns start nullable, backfilled, and validated before constraints.
- Incremental refactors: Only touch query paths needed for surveys before full conversion.
- 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
- Branching transitions (v2)
- Survey versioning
- Go-back navigation + progress semantics
- Broad analytics/reporting refactors
Outcome: Create core tables and models without touching existing poll tables.
# 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# 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# 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
endtest/models/condition_test.rb+ subtype teststest/models/survey_test.rbtest/models/survey/page_test.rbtest/models/survey/journey_test.rbtest/models/content_test.rb
Outcome: Steps support container_type/id while still supporting poll_id.
# 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# 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- Extract
PositionablefromStep::Positionabletoapp/models/concerns/positionable.rb. - Update
Step::Positionableto includePositionable. - Use
PositionableinSurvey::Page.
- 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.
- Step container backfill
- Shared Positionable ordering for
Survey::PageandStep - Survey duplication + container integrity
Outcome: Surveys can be created as resources and accessed via response links.
# 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# 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
endSurveysControllerfor CRUD (behind feature flag)Survey::PagesControllerfor page CRUDResponseLinksControllerallows survey response link creation
- ResponseLink constraint validation tests
- Survey creation via Resource
Outcome: Conditions are enforced and safe across pages/steps.
Condition#applies_to?(journey)for each STI typeSurvey::Page#visible_for?Step#visible_for?
Survey::Pagevalidation:condition.question_idmust refer to a question on a lower position pageStepvalidation:condition.question_idmust refer to a question earlier in page position or earlier page
- Page/step visibility rules
- Invalid condition rejection
Outcome: Builder supports multi-page surveys with conditions.
SurveysControllerfor create/editSurvey::PagesControllerfor add/remove/reorder- Steps creation uses current page container
- Condition UI for page and step visibility
- Drag/drop ordering for pages and steps
- Persist via Positionable concerns
- Controller tests for page CRUD and ordering
- System test: create survey → add page → add step → set condition
Outcome: Survey link renders pages/steps with visibility.
/surveys/:survey_id/respond/:response_link_id(pattern parallel to polls)- Use response link to create or resume journey
- Survey renderer page view
- Steps list per page using
visible_steps_on_current_page - Completion page
- Create journey at first visible page
advance!moves to next visible page or completes
- Integration test: response link → journey → completion
- System test: survey with visibility skips hidden pages
Outcome: Containerized steps are first-class without risky global changes.
- Builder queries use
Step.where(container: container) - Duplication uses container-aware step copying
- Survey exports/reporting use container-based steps
- Poll-only analytics/reporting queries remain poll-specific
- Future work list for container migration
- Poll regression tests for builder
Outcome: Enforce container constraints and remove poll_id once adoption is complete.
# 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# 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# 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
- Unit: Condition STI, Survey/Page/Journey, Step container
- Integration: ResponseLink, Survey builder, Journey navigation
- System: participant flow + builder multi-page flow
- Anonymity in
Survey::Journey#response_for(avoid deanonymization). - Auto-create response links on publish vs manual creation.
- Progress indicator semantics with visibility.