Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,9 @@ Analyst/reviewer DB credentials are minted out of band of `stele_api` (design do

1. `app.raw_responses` is append-only and the sole ETL source. dbt reads it only, never the normalized `app.responses` / `app.response_items` read-model.
2. Published surveys are immutable. Edits → new draft → new version → new hash. Every response carries the hash it was answered against.
3. Shown-set is captured at submit time from the SurveyJS engine. Never reconstruct routing by re-evaluating `visibleIf` in SQL.
3. Shown-set AND render order are captured at submit time from the SurveyJS engine. `shown_questions` is in render order; `shown_choice_orders` carries the rendered choice order for any question whose options were randomized. Never reconstruct routing or display order by re-evaluating the definition in SQL. (Design doc §3.5 "Randomization and display-order capture", §4.11.)
4. One JSON parser. API → operational read-model. dbt → warehouse. Both from `raw_responses`. Never chain them. *Enforced by `scripts/check_invariants.py`, which scans dbt SQL for forbidden table references and verifies `sources.yml` declares only `raw_responses` under the `app` schema. Pre-commit runs it; CI verifies.*
5. No cross-version pooling by default. Opt-in via `dim_question.parent_question_id` + `parent_question_rationale`. Never auto-populate either. *Lint checks that writes to `parent_question_id` co-occur with `parent_question_rationale`.*
5. No cross-version pooling by default. Opt-in via `dim_question.parent_question_id` + `parent_question_rationale`. Never auto-populate either. `construct_block` / `construct_item` are **provenance, not a pooling key** — they surface "this is a PHQ-9 item" but never license `GROUP BY construct_block` across surveys; pooling stays the `parent_question_id` opt-in. *Lint checks that writes to `parent_question_id` co-occur with `parent_question_rationale`; `construct_pair_integrity` enforces item⇒block; `construct_tag_stability` rejects within-survey tag drift across versions.*
6. Free-text defaults to `pii_risk = 'high'`. `marts.fact_response_item.value_text` populated only for explicit `low`. *Lint checks that `value_text` writes to `fact_response_item` reference `pii_risk`.*
7. Fact grain: `(respondent, survey_version, question_id, occurrence, selected_option)`. `fact_response` (respondent-question grain) is a separate table. Don't conflate.
8. Exactly one of `{option_key, value_numeric, value_text, value_date}` per fact row. dbt test enforces; don't skip.
Expand Down Expand Up @@ -86,11 +86,12 @@ Work on branches; never commit directly to `main`. One branch per story/unit of

## Don't add silent defaults for methodological judgments

Three judgments stay explicit, always. Examples of what going-wrong-silently looks like:
Four judgments stay explicit, always. Examples of what going-wrong-silently looks like:

- **Cross-version equivalence.** Don't auto-fill `parent_question_rationale` from prompt similarity between v1 and v2. That's a researcher's call, not a heuristic.
- **Cross-version equivalence.** Don't auto-fill `parent_question_rationale` from prompt similarity between v1 and v2. That's a researcher's call, not a heuristic. Same for `construct_block` ⇒ `parent_question_id`: a shared scale tag is provenance, not equivalence — don't promote it.
- **Free-text safety.** Don't downgrade a question from `pii_risk = 'high'` to `'low'` because the prompt "looks innocuous." The default is `'high'`; downgrades are deliberate decisions at definition time.
- **Shown vs skipped vs routed-past.** Don't collapse `was_shown = false` and `was_shown = true, value null` into "missing." They mean different things; analyses depend on the distinction.
- **Display order.** Don't render randomized questions/choices without writing the realized order back to `raw_responses` (in `shown_questions` order + `shown_choice_orders`). Don't reconstruct `display_order` in SQL from the published definition — the definition holds the rule, not the per-respondent realization. Treat order capture as part of the submission contract, the same way the shown-set is.

If you're writing a default for any of these, that's the bug.

Expand All @@ -100,6 +101,8 @@ Schema validation → lint (dup names, dangling `visibleIf`, dup option values,

New question type = work in three places: runtime, publish test, dbt staging.

Within-page (`Page.questionsOrder`), within-matrix (`Matrix.rowsOrder`), and within-question (`SelectBase.choicesOrder`) randomization are unconstrained — values are stable across re-orderings, and `visibleIf` reads values, not positions. Static-panel and survey-level `pagesOrder` shuffling aren't wired today (static panel is outside the accepted type surface; `pagesOrder` isn't native SurveyJS) — deferred per design doc §5. The page-order extension lands with its own publish-gate lint (reject cross-page `visibleIf` under page randomization).

## dbt

- Staging = views; intermediate + marts = tables; no materialized views.
Expand All @@ -114,7 +117,8 @@ New question type = work in three places: runtime, publish test, dbt staging.
- Give `stele_api` `CREATEROLE`, or put credential role-DDL in the request path. Role-minting lives ONLY in `api.credential_worker` / the `scripts/provision_db_credential.py` CLI over `STELE_PROVISION_DATABASE_URL`; the API just enqueues into `app.provision_requests`. And `stele_analyst`/`stele_pii_reviewer` must never accumulate grants beyond their one schema (`marts`/`pii`) — that boundary is what keeps a leaked analyst credential low-stakes.
- Make dbt read normalized `app.*` tables.
- `UPDATE`/`DELETE` `raw_responses` outside the tombstone workflow.
- Auto-populate `parent_question_id` from any heuristic.
- Auto-populate `parent_question_id` from any heuristic — including a shared `construct_block` (provenance, not equivalence; design doc §4.10).
- Reconstruct per-respondent display order in SQL from the published definition. The definition holds the rule (`questionsOrder: 'random'`, etc.); the per-respondent realization is captured at submit time and read straight through to `fact_response_item.display_order` (design doc §4.11).
- Add a new runtime component (broker, search, separate analytics DB) — check design doc § 5 triggers first.
- Postgres-specific SQL in dbt without dispatch or a documented reason.
- `git rm` survey definitions or response data. Use the database.
Expand Down
1 change: 1 addition & 0 deletions patterns/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ a panel free-text cell stays `value_text` regardless of `inputType`.
| [branching.json](branching.json) | `visibleIf` conditional routing, including complementary branches |
| [multi_page.json](multi_page.json) | multiple pages; a `visibleIf` referencing an answer from an earlier page |
| [calculated_values.json](calculated_values.json) | a `calculatedValue` as a reusable, named `visibleIf` condition |
| [randomization.json](randomization.json) | `questionsOrder: 'random'` (page + panel) and `choicesOrder: 'random'`, plus the block-aware shape (a `construct_block`-tagged panel shuffling its items while staying contiguous); per-respondent display order is captured at submit time and threads to `fact_response_item.display_order` |

Each file annotates itself inline: the survey- and question-level `title` and
`description` fields are real SurveyJS properties (they render in the runtime), so
Expand Down
76 changes: 76 additions & 0 deletions patterns/randomization.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
{
"title": "Randomization (question order, matrix-row order, choice order)",
"description": "questionsOrder: 'random' on a page shuffles its questions per respondent; rowsOrder: 'random' on a matrix shuffles its row sub-questions; choicesOrder: 'random' on a single/multi-select/ranking shuffles its options. The order each respondent actually saw is captured at submission time — shown_questions in render order, shown_choice_orders for randomized choices — and threads to fact_response_item.display_order (design doc §3.5 'Randomization and display-order capture'; never reconstructed in SQL, §4.11). Block-aware randomization is structural, not a separate flag: a reusable scale fits naturally as a matrix tagged with construct_block (the tag inherits to row sub-questions per §3.5 'Construct membership'), so PHQ-9 items shuffle among themselves and stay contiguous because the matrix is the boundary. visibleIf reads values, not display positions, so within-page / within-matrix / within-question randomization is always safe to combine with branching. Static-panel and survey-level page shuffling aren't wired today (deferred, design doc §5).",
"pages": [
{
"name": "p_warmup",
"questionsOrder": "random",
"title": "Warm-up questions (randomized)",
"description": "questionsOrder: 'random' on a page shuffles the page's elements per respondent. The two questions here are unrelated, so order is the only thing that changes.",
"elements": [
{
"type": "radiogroup",
"name": "coffee",
"title": "Coffee or tea?",
"choices": ["coffee", "tea", "neither"]
},
{
"type": "radiogroup",
"name": "morning_or_night",
"title": "Morning person or night owl?",
"choices": ["morning", "night", "depends"]
}
]
},
{
"name": "p_scale",
"elements": [
{
"type": "matrix",
"name": "phq9",
"title": "Over the last two weeks, how often have you been bothered by…",
"description": "Matrix with rowsOrder: 'random' is the block-aware-randomization shape: each row is its own sub-question (stable_name 'phq9.q1', 'phq9.q2', 'phq9.q3'), the shared response scale lives in columns, and the rows shuffle per respondent. construct_block on the matrix inherits to the row sub-questions in dim_question (design doc §3.5 'Construct membership' inheritance rule), so each row carries construct_block='phq9'. construct_item is leaf-only — set per row, not on the container.",
"construct_block": "phq9",
"rowsOrder": "random",
"rows": [
{ "value": "q1", "text": "Little interest or pleasure in doing things", "construct_item": "phq9_q1" },
{ "value": "q2", "text": "Feeling down, depressed, or hopeless", "construct_item": "phq9_q2" },
{ "value": "q3", "text": "Trouble falling/staying asleep, or sleeping too much", "construct_item": "phq9_q3" }
],
"columns": [
{ "value": "0", "text": "Not at all" },
{ "value": "1", "text": "Several days" },
{ "value": "2", "text": "More than half the days" },
{ "value": "3", "text": "Nearly every day" }
]
}
]
},
{
"name": "p_choices",
"elements": [
{
"type": "radiogroup",
"name": "favorite_color",
"title": "Pick a favorite color",
"description": "choicesOrder: 'random' shuffles the options shown to this respondent. The chosen option_key is unchanged — selection is by value, not position — so dim_option.display_order (the authored position) and the per-respondent rendered position are different things. The per-respondent rendered choice order lives in raw_responses.shown_choice_orders; a fact-side choice_display_order column is deferred (design doc §5 'Choice-order analysis surface').",
"choicesOrder": "random",
"choices": [
{ "value": "red", "text": "Red" },
{ "value": "green", "text": "Green" },
{ "value": "blue", "text": "Blue" },
{ "value": "yellow", "text": "Yellow" }
]
},
{
"type": "radiogroup",
"name": "color_followup",
"title": "Is that the color of something you own?",
"description": "visibleIf reads the chosen value ({favorite_color}), not its rendered position, so choices-order randomization never affects branching reachability. The publish gate's round-trip oracle treats randomized choices as order-invariant for the same reason (design doc §3.6).",
"visibleIf": "{favorite_color} notempty",
"choices": ["yes", "no", "not_sure"]
}
]
}
]
}
Loading
Loading