From 450c527d1e05afbc48860ec60deaef314e6f9b63 Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Sat, 5 Sep 2026 19:32:12 +0100 Subject: [PATCH] docs(question-types): document interaction contract --- ...2026-09-05-question-type-contract-drift.md | 40 +++++++++++++++++ .codex/skills/question-pack/SKILL.md | 5 +++ AGENTS.md | 3 ++ docs/index.md | 1 + docs/question-types.md | 45 +++++++++++++++++++ docs/schema.md | 25 ++++++++--- scripts/lint-harness.mjs | 19 +++++++- scripts/validate-learnings.mjs | 5 ++- 8 files changed, 135 insertions(+), 8 deletions(-) create mode 100644 .agents/learnings/promoted/2026-09-05-question-type-contract-drift.md create mode 100644 docs/question-types.md diff --git a/.agents/learnings/promoted/2026-09-05-question-type-contract-drift.md b/.agents/learnings/promoted/2026-09-05-question-type-contract-drift.md new file mode 100644 index 0000000..3e06701 --- /dev/null +++ b/.agents/learnings/promoted/2026-09-05-question-type-contract-drift.md @@ -0,0 +1,40 @@ +--- +id: 2026-09-05-question-type-contract-drift +date: 2026-09-05 +status: promoted +scope: question interaction contract documentation +evidence: npm run lint:harness and npm run learn:check +destination: docs/question-types.md, scripts/lint-harness.mjs, AGENTS.md, and .codex/skills/question-pack/SKILL.md +review_after: 2026-12-05 +--- + +## Symptom + +Maintained documentation did not identify the implemented question interaction +types, their response payloads, or their matching semantics. The only detailed +reference was a historical implementation plan, so content and product work +could not rely on maintained documentation. + +## Reproduction + +Compare `QuestionType` in `packages/shared/src/types/index.ts` and the question +renderer and answer matching code with `docs/index.md`, `docs/schema.md`, and +`docs/question-selection.md`. + +## Cause + +The interaction contract was added to code without a maintained documentation +owner or a verification check linking the document to the canonical union. + +## Remediation + +Create `docs/question-types.md` as the maintained interaction contract. Require +agents and the question-pack workflow to update it with type or semantic +changes. Add a harness invariant that compares its type marker to the shared +`QuestionType` union. + +## Evidence + +`npm run lint:harness` reads the shared union and the documentation marker and +fails when their ordered type lists differ. `npm run learn:check` validates this +promoted learning record and its destination metadata. diff --git a/.codex/skills/question-pack/SKILL.md b/.codex/skills/question-pack/SKILL.md index 59eaf1c..1769214 100644 --- a/.codex/skills/question-pack/SKILL.md +++ b/.codex/skills/question-pack/SKILL.md @@ -7,6 +7,11 @@ description: Create or review Sauci question-pack content and its local migratio Inspect existing packs before generating to avoid duplication. +Before creating or editing content that uses a question interaction type, read +`docs/question-types.md`. If the type, response payload, configuration, or +matching semantics change, update that maintained contract and run +`npm run lint:harness` in the same task. + - Write proposals, not interview questions; use gender-neutral “your partner”. - Avoid time-bound words such as tonight, now, or today. - Symmetric activities have no partner text. diff --git a/AGENTS.md b/AGENTS.md index dc14b5c..40e4466 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,6 +87,9 @@ environment limitation; do not call it a passing product check. ## Documentation discipline Update the maintained doc when a contract, command, port, or invariant changes. +When question interaction types, response payloads, configuration, or match +semantics change, update `docs/question-types.md` in the same change and run +`npm run lint:harness` to prove its type list matches the shared contract. Plans and historical notes are evidence, not current architecture. A repeated lesson becomes permanent only after it is reproduced and promoted into a doc, skill, fixture, or executable check. diff --git a/docs/index.md b/docs/index.md index 26df3e9..8937dd9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -20,6 +20,7 @@ historical notes. Agents should load only the section relevant to their task. - [Couple pairing](couple-pairing.md) - [Couple streaks](couple-streaks.md) - [Question selection](question-selection.md) +- [Question interaction types](question-types.md) - [Match notifications](match-notifications.md) - [Subscriptions](subscription-system.md) - [Redemption codes](product/redemption.md) diff --git a/docs/question-types.md b/docs/question-types.md new file mode 100644 index 0000000..e12067a --- /dev/null +++ b/docs/question-types.md @@ -0,0 +1,45 @@ +# Question interaction types + +This is the maintained contract for the question interactions delivered through +the daily question flow. It is separate from the [couples quiz](quiz.md) and +[dares loop](dares-loop.md), which have their own content and completion +models. + + + +## Supported types + +| Type | User interaction | `response_data` and `config` | Match semantics | +| --- | --- | --- | --- | +| `swipe` | Choose yes or no, or skip. | No `response_data`; `config` is unused. | A no creates no match. The API also retains `maybe` matching for stored or replayed answers, producing `yes_maybe` or `maybe_maybe`; the current mobile card does not offer maybe. | +| `text_answer` | Write an answer and send it, decline with no, or skip. | `response_data`: `{ type: 'text_answer', text }`; `config` is unused. | Both partners must answer without a no. The match type is `both_answered`, with both payloads in `response_summary`. | +| `audio` | Record and send an audio answer, or skip. | `response_data`: `{ type: 'audio', media_path, duration_seconds }`; optional `config.max_duration_seconds` limits recording and defaults to 60 seconds in the admin UI. | Both partners must answer without a no. The match type is `both_answered`, with both payloads in `response_summary`. | +| `photo` | Take or select and send a photo answer, or skip. | `response_data`: `{ type: 'photo', media_path }`; `config` is unused. | Both partners must answer without a no. The match type is `both_answered`, with both payloads in `response_summary`. | +| `who_likely` | Choose which partner is more likely. The current mobile flow submits this as yes. | `response_data`: `{ type: 'who_likely', chosen_user_id }`; `config` is unused. | Once both partners respond, the match type is `both_answered`; the stored answer value does not gate that type's match calculation. Both choices are in `response_summary`. | + +## Persistence and implementation ownership + +`questions.question_type` defaults to `swipe`; the database check constraint and +the canonical shared `QuestionType` union must contain the same values. +`questions.config` is JSON configuration for a type. `responses.response_data` +stores an individual non-swipe payload. `matches.response_summary` is a JSON +object keyed by responder user ID and is populated for `both_answered` matches. + +The canonical list is `QuestionType` in +[`packages/shared/src/types/index.ts`](../packages/shared/src/types/index.ts). +The database constraint is in +[`apps/api/drizzle/0003_answers_matches.sql`](../apps/api/drizzle/0003_answers_matches.sql). +Match calculation is in +[`apps/api/src/domains/answers/types.ts`](../apps/api/src/domains/answers/types.ts), +the mobile renderer is +[`apps/mobile/src/features/swipe/components/SwipeQuestionCard.tsx`](../apps/mobile/src/features/swipe/components/SwipeQuestionCard.tsx), +and admin authoring options are in +[`apps/admin/src/pages/content/QuestionsPage.tsx`](../apps/admin/src/pages/content/QuestionsPage.tsx). + +## Change rule + +When adding, removing, or changing a question type, its payload, configuration, +or matching semantics, update this document in the same change. Update the +shared type, database constraint, API, mobile, and admin consumers as required. +`npm run lint:harness` checks the documented type list against the canonical +shared union so an unreviewed list change fails routine verification. diff --git a/docs/schema.md b/docs/schema.md index f24c559..16806fd 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -47,6 +47,8 @@ erDiagram uuid pack_id FK text text text partner_text "for two-part questions" + text question_type "swipe/text_answer/audio/photo/who_likely" + jsonb config "type-specific configuration" int intensity "1-5" text[] allowed_couple_genders "couple composition filter" text[] target_user_genders "individual user filter" @@ -70,6 +72,7 @@ erDiagram uuid question_id FK uuid couple_id FK "nullable: null is a sealed solo answer, claimed at pairing" answer_type answer "yes/no/maybe" + jsonb response_data "non-swipe answer payload" timestamptz created_at } @@ -77,7 +80,8 @@ erDiagram uuid id PK uuid couple_id FK uuid question_id FK - match_type match_type "yes_yes/yes_maybe/maybe_maybe" + match_type match_type "yes_yes/yes_maybe/maybe_maybe/both_answered" + jsonb response_summary "both non-swipe payloads by user" boolean is_new timestamptz created_at } @@ -161,7 +165,8 @@ erDiagram | Enum | Values | |------|--------| | `answer_type` | `yes`, `no`, `maybe` | -| `match_type` | `yes_yes`, `yes_maybe`, `maybe_maybe` | +| `question_type` | `swipe`, `text_answer`, `audio`, `photo`, `who_likely` | +| `match_type` | `yes_yes`, `yes_maybe`, `maybe_maybe`, `both_answered` | | `feedback_type` | `bug`, `feature_request`, `general` | | `feedback_status` | `new`, `reviewed`, `in_progress`, `resolved`, `closed` | | `subscription_status` | `active`, `cancelled`, `expired`, `billing_issue`, `paused` | @@ -188,11 +193,21 @@ allowed. ## Key Flows +### Question interactions + +`questions.question_type` defaults to `swipe`; `questions.config` holds +type-specific settings. A non-swipe response keeps its payload in +`responses.response_data`. When a non-swipe interaction resolves as +`both_answered`, `matches.response_summary` holds both payloads keyed by user +ID. The complete interaction and match contract is maintained in +[Question interaction types](question-types.md). + ### Response & Match Flow -1. User submits response via `submit-response` edge function +1. User submits a response to the standalone API at `POST /v1/responses` 2. Response saved to `responses` table (UPSERT on `user_id, question_id`) -3. Function checks for partner's response on same question -4. If both answered positively (not "no"), a `match` is created +3. API checks for the partner's response on the same question +4. API applies the [question interaction contract](question-types.md) to create, + update, or remove a `match` 5. Match triggers push notification to both partners 6. Match unlocks chat thread in `messages` table diff --git a/scripts/lint-harness.mjs b/scripts/lint-harness.mjs index ed8a046..e60840a 100644 --- a/scripts/lint-harness.mjs +++ b/scripts/lint-harness.mjs @@ -1,7 +1,8 @@ import { access, readFile, readdir } from 'node:fs/promises'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; -const root = path.resolve(new URL('..', import.meta.url).pathname); +const root = path.resolve(fileURLToPath(new URL('..', import.meta.url))); const errors = []; const scoped = process.argv.includes('--scope'); @@ -156,6 +157,22 @@ if (!scoped) { if (!(await exists(target))) fail(path.join(root, 'docs', 'index.md'), `broken documentation link ${match[1]}`, 'correct the link or restore the maintained document'); } + const sharedTypesFile = path.join(root, 'packages', 'shared', 'src', 'types', 'index.ts'); + const sharedTypes = await readFile(sharedTypesFile, 'utf8'); + const union = sharedTypes.match(/export type QuestionType\s*=\s*([^;]+);/); + const canonicalQuestionTypes = union?.[1].match(/'([^']+)'/g)?.map((value) => value.slice(1, -1)); + const questionTypesDoc = path.join(root, 'docs', 'question-types.md'); + const questionTypesSource = await readFile(questionTypesDoc, 'utf8'); + const documentedQuestionTypes = questionTypesSource.match(//)?.[1] + .split(',').map((value) => value.trim()).filter(Boolean); + if (!canonicalQuestionTypes?.length) { + fail(sharedTypesFile, 'QuestionType union could not be read', 'keep QuestionType as a quoted string union'); + } else if (!documentedQuestionTypes?.length) { + fail(questionTypesDoc, 'question type contract marker is missing', 'add with every supported type'); + } else if (canonicalQuestionTypes.join(',') !== documentedQuestionTypes.join(',')) { + fail(questionTypesDoc, 'documented question types differ from the shared QuestionType union', `set the contract marker to ${canonicalQuestionTypes.join(',')}`); + } + const mcpAuth = await readFile(path.join(root, 'apps', 'mcp', 'src', 'lib', 'auth.ts'), 'utf8'); if (/allowing all requests|query\(['"]apiKey/i.test(mcpAuth)) { fail(path.join(root, 'apps', 'mcp', 'src', 'lib', 'auth.ts'), 'MCP authentication can leak or fail open', 'require a configured Bearer token and fail closed'); diff --git a/scripts/validate-learnings.mjs b/scripts/validate-learnings.mjs index c0fdcc1..74432b4 100644 --- a/scripts/validate-learnings.mjs +++ b/scripts/validate-learnings.mjs @@ -1,7 +1,8 @@ import { readFile, readdir } from 'node:fs/promises'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; -const root = path.resolve(new URL('..', import.meta.url).pathname); +const root = path.resolve(fileURLToPath(new URL('..', import.meta.url))); const base = path.join(root, '.agents', 'learnings'); const requiredSections = ['## Symptom', '## Reproduction', '## Cause', '## Remediation', '## Evidence']; const failures = []; @@ -11,7 +12,7 @@ for (const state of ['inbox', 'promoted']) { for (const name of await readdir(directory)) { if (!name.endsWith('.md')) continue; const file = path.join(directory, name); - const source = await readFile(file, 'utf8'); + const source = (await readFile(file, 'utf8')).replace(/\r\n/g, '\n'); const frontmatter = source.match(/^---\n([\s\S]*?)\n---/); if (!frontmatter) { failures.push(`${file}: missing YAML frontmatter`); continue; } const fields = Object.fromEntries(frontmatter[1].split('\n').map((line) => {