mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 10:19:43 +00:00
docs(question-types): document interaction contract
This commit is contained in:
1 parent
53a48ab6d5
commit
450c527d1e
8 files changed
+135
-8
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
<!-- question-types: swipe,text_answer,audio,photo,who_likely -->
|
||||
|
||||
## 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.
|
||||
+20
-5
@@ -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
|
||||
|
||||
|
||||
@@ -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(/<!-- question-types:\s*([^>]+)\s*-->/)?.[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 <!-- question-types: type-a,type-b --> 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');
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user