Author: Architecture Design
Date: 2026-02-02
Amp Thread: T-019c1fb2-3b97-73e9-9463-f4eb68f1e8b8
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.
- Reuse existing Step model — polymorphic container enables both Poll and Page to contain steps
- Support page visibility logic — pages can be conditionally shown/hidden based on prior responses
- Follow established patterns — 37signals delegated types, Fizzy concern organization, existing Artemis conventions
- Design for future branching — visibility conditions on pages are compatible with later edge-based navigation
- 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
| 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 |
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.
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
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
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)
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
endConcerns:
Auditable— activity loggingConfigurable— settings managementDuplicable— copy/duplicate functionalityResourceable— integration with Resource hierarchyTitleable— title handling with defaults
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
endVisibility 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
endGeneric 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
endRefactored 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
endNew 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
endAdd Survey to delegated types.
# app/models/resource.rb (modification)
delegated_type :resourceable, types: %w[Folder Poll Survey]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
endUpdate 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# 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# 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
endContainer 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# 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# 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
endapp/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
| 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 substringresponse_gt/response_lt— numeric comparisonsparticipant_property— condition on user attributes
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
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 |
If respondent answers Q1 = "yes":
- Page 1 (visible) → show
- Page 2 (visible, condition matches) → show
- Page 3 (not visible, condition fails) → skip
- Page 4 (visible) → show
- No more pages → complete
- Go back navigation — Can users go back? Does it clear responses? Show warning?
- Progress indicator — Show percentage? Page numbers? Neither?
- Partial save — Auto-save on page advance? Manual save button?
- Resume later — Allow anonymous resume via token/cookie?
- Editing while live — Block edits? Allow non-breaking edits? Versioning?
- Shared Positionable concern
- Survey model with Resource integration
- Survey::Page with visibility conditions
- TextBlock steppable type
- Step polymorphic container migration
- Database migrations
- SurveySubmission model
- Visibility-based navigation
- ResponseLink integration
- Page management (add, remove, reorder)
- Step management within pages
- Visibility condition configuration UI
- Survey renderer
- Navigation controls
- Completion tracking
- Thank you / completion page
- Survey::PageTransition model
- Edge-based navigation
- Graph validation (cycles, dead ends, unreachable nodes)
- Transition configuration UI
| 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 |