From af02da5bc494bf74616d7d0a22fcfa1b2c6b889a Mon Sep 17 00:00:00 2001 From: Obvious Date: Thu, 17 Sep 2026 18:35:09 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20caregiver=20questions=20=E2=80=94=20aud?= =?UTF-8?q?ience-aware=20contracts,=20fail-closed=20policy,=20fixtures,=20?= =?UTF-8?q?UI=20prototype?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the Journal & Caregiver Coordination lane's caregiver-question contract on Effect v4 schemas: - packages/domain: careQuestions + careQuestionActivities schemas (entry/event targets, directed vs household audience, append-only lifecycle), pure deriveQuestionState, fail-closed policy (view/ask/answer/resolve/reopen with deny codes), handoff digest distinguishing no-questions from unresolved, operation contracts, index exports, 54 fixture-driven tests - evaluation/fixtures/agent-experience/caregiver-questions/: two-household synthetic corpus (lifecycle, draft visibility, directed isolation, explicit negatives, handoff exclusion, cross-household denial) - apps/mobile: pure viewer logic + prototype screen (viewer switcher, answer/ resolve/reopen gated by the domain policy, handoff digest card) + App toggle - backend/convex: careQuestions/careQuestionActivities tables via the adapter Verified at this commit: pnpm typecheck (7/7), pnpm test (54+10 pass), pnpm build (3/3), bun test ./security (17/17), evaluation corpus 6/6 with negative control failing as designed. --- apps/mobile/App.tsx | 41 +- apps/mobile/package.json | 3 + .../src/questions/CareQuestionsDemo.tsx | 311 ++++++++++ apps/mobile/src/questions/logic.test.ts | 195 +++++++ apps/mobile/src/questions/logic.ts | 93 +++ apps/mobile/tsconfig.json | 2 +- backend/convex/convex/schema.ts | 14 +- .../caregiver-questions/AREA.md | 64 +++ .../caregiver-questions/household-a.json | 243 ++++++++ .../caregiver-questions/household-b.json | 54 ++ packages/domain/src/careQuestion.ts | 303 ++++++++++ packages/domain/src/careQuestionPolicy.ts | 250 ++++++++ packages/domain/src/contracts.ts | 60 ++ packages/domain/src/index.ts | 47 ++ packages/domain/test/careQuestion.test.ts | 539 ++++++++++++++++++ pnpm-lock.yaml | 6 + 16 files changed, 2220 insertions(+), 5 deletions(-) create mode 100644 apps/mobile/src/questions/CareQuestionsDemo.tsx create mode 100644 apps/mobile/src/questions/logic.test.ts create mode 100644 apps/mobile/src/questions/logic.ts create mode 100644 evaluation/fixtures/agent-experience/caregiver-questions/AREA.md create mode 100644 evaluation/fixtures/agent-experience/caregiver-questions/household-a.json create mode 100644 evaluation/fixtures/agent-experience/caregiver-questions/household-b.json create mode 100644 packages/domain/src/careQuestion.ts create mode 100644 packages/domain/src/careQuestionPolicy.ts create mode 100644 packages/domain/test/careQuestion.test.ts diff --git a/apps/mobile/App.tsx b/apps/mobile/App.tsx index 553d2d4..ea38aea 100644 --- a/apps/mobile/App.tsx +++ b/apps/mobile/App.tsx @@ -1,18 +1,34 @@ import { ConvexProvider, ConvexReactClient } from "convex/react" -import { useMemo } from "react" -import { StyleSheet, View } from "react-native" +import { useMemo, useState } from "react" +import { Pressable, StyleSheet, View } from "react-native" import { Text } from "@journal/ui" import { getConvexClientUrl } from "./src/lib/convex" +import { CareQuestionsDemo } from "./src/questions/CareQuestionsDemo" export default function App() { const client = useMemo(() => new ConvexReactClient(getConvexClientUrl()), []) + const [screen, setScreen] = useState<"journal" | "questions">("questions") return ( - Shared Child Journal + + setScreen("questions")} + > + Questions + + setScreen("journal")} + > + Journal + + + {screen === "journal" ? Shared Child Journal : } ) @@ -24,4 +40,23 @@ const styles = StyleSheet.create({ flex: 1, justifyContent: "center", }, + switchRow: { + flexDirection: "row", + gap: 8, + marginBottom: 16, + marginTop: 48, + }, + switchButton: { + borderRadius: 16, + borderWidth: 1, + borderColor: "#bbb", + paddingHorizontal: 14, + paddingVertical: 6, + }, + switchActive: { + backgroundColor: "#333", + borderColor: "#333", + }, + switchText: { color: "#333" }, + switchTextActive: { color: "#fff" }, }) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 92ac6e9..b1c1b7e 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -5,11 +5,13 @@ "main": "index.ts", "scripts": { "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "bun test", "start": "expo start", "android": "expo start --android", "ios": "expo start --ios" }, "dependencies": { + "@journal/domain": "workspace:*", "@journal/ui": "workspace:*", "convex": "^1.46.0", "effect": "4.0.0-rc.115", @@ -18,6 +20,7 @@ "react-native": "0.86.3" }, "devDependencies": { + "@types/bun": "^1.3.14", "@types/node": "^20.19.0", "@types/react": "~19.2.2", "typescript": "~6.0.3" diff --git a/apps/mobile/src/questions/CareQuestionsDemo.tsx b/apps/mobile/src/questions/CareQuestionsDemo.tsx new file mode 100644 index 0000000..52549b1 --- /dev/null +++ b/apps/mobile/src/questions/CareQuestionsDemo.tsx @@ -0,0 +1,311 @@ +/** + * Prototype screen for the caregiver-questions contract (Journal & Caregiver + * Coordination lane). In-memory seed mirroring + * evaluation/fixtures/agent-experience/caregiver-questions/household-a.json — + * activities are appended locally and nothing is written to Convex yet. + * + * What this demonstrates beyond transcription: + * - one question card per open thread, with lifecycle status and reopen count + * - fail-closed visibility per viewer (switch viewers to see the list change) + * - the asker of a DIRECTED question resolves instead of answering + * - a handoff digest card that renders "no questions asked" as its own fact + */ +import { useMemo, useState } from "react" +import { Pressable, ScrollView, StyleSheet, TextInput, View } from "react-native" + +import { deriveQuestionState } from "@journal/domain" +import type { CareQuestionActivityDocument, CareQuestionDocument, QuestionTargetFacts } from "@journal/domain" + +import { Text } from "@journal/ui" + +import { digestFor, formatEpochTime, handoffSummary, statusLabel, viewerCanAnswer, viewerCanReopen, viewerCanResolve, visibleSeeds, type QuestionSeed } from "./logic" + +const HOUSEHOLD_A = "household_a" +const CHILD = "child_a" + +interface Viewer { + readonly id: string + readonly label: string + readonly householdIds: readonly string[] +} + +const VIEWERS: readonly Viewer[] = [ + { id: "caregiver-mom", label: "Mom", householdIds: [HOUSEHOLD_A] }, + { id: "caregiver-dad", label: "Dad", householdIds: [HOUSEHOLD_A] }, + { id: "caregiver-grandma", label: "Grandma", householdIds: [HOUSEHOLD_A] }, + { id: "caregiver-nanny", label: "Nanny", householdIds: [HOUSEHOLD_A, "household_b"] }, +] + +interface EntrySeed { + readonly id: string + readonly label: string + readonly authorId: string + readonly visibility: "draft" | "published" +} + +const ENTRIES: readonly EntrySeed[] = [ + { id: "entry_meal", label: "Lunch entry", authorId: "caregiver-dad", visibility: "published" }, + { id: "entry_sleep", label: "Nap entry", authorId: "caregiver-dad", visibility: "published" }, + { id: "entry_outdoor", label: "Outdoor play (Nanny's draft)", authorId: "caregiver-nanny", visibility: "draft" }, +] + +const targetOf = (entryId: string): QuestionTargetFacts => { + const entry = ENTRIES.find((e) => e.id === entryId) + if (!entry) throw new Error(`unknown entry ${entryId}`) + return { + householdId: HOUSEHOLD_A, + childId: CHILD, + authorId: entry.authorId, + visibility: entry.visibility, + } +} + +const baseQuestion = (overrides: Partial): CareQuestionDocument => ({ + _id: "q_seed", + _creationTime: 1789651800000, + householdId: HOUSEHOLD_A, + childId: CHILD, + entryId: "entry_meal", + targetKind: "entry", + askedById: "caregiver-mom", + audienceKind: "household", + addresseeIds: [], + handoffIncluded: true, + question: "Did she drink water with lunch?", + createdAt: 1789651800000, + ...overrides, +}) + +const SEED_ACTIVITIES: readonly CareQuestionActivityDocument[] = [ + { + _id: "act_nap_answer", + _creationTime: 1789655400000, + householdId: HOUSEHOLD_A, + childId: CHILD, + questionId: "q_nap", + kind: "answered", + at: 1789655400000, + answerText: "Two naps, about 45 minutes each.", + actorId: "caregiver-dad", + }, + { + _id: "act_potty_answer", + _creationTime: 1789659000000, + householdId: HOUSEHOLD_A, + childId: CHILD, + questionId: "q_potty", + kind: "answered", + at: 1789659000000, + answerText: "None today.", + actorId: "caregiver-dad", + }, + { + _id: "act_potty_resolve", + _creationTime: 1789662600000, + householdId: HOUSEHOLD_A, + childId: CHILD, + questionId: "q_potty", + kind: "resolved", + at: 1789662600000, + actorId: "caregiver-mom", + }, +] + +const initialSeeds = (): QuestionSeed[] => [ + { + question: baseQuestion({ _id: "q_water", question: "Did she drink water with lunch?" }), + activities: [], + target: targetOf("entry_meal"), + }, + { + question: baseQuestion({ + _id: "q_nap", + entryId: "entry_sleep", + question: "How long were her naps today?", + createdAt: 1789651800000 + 1, + audienceKind: "directed", + addresseeIds: ["caregiver-dad"], + }), + activities: SEED_ACTIVITIES.filter((a) => a.questionId === "q_nap"), + target: targetOf("entry_sleep"), + }, + { + question: baseQuestion({ _id: "q_potty", question: "Any potty accidents after lunch?", createdAt: 1789651800000 + 2 }), + activities: SEED_ACTIVITIES.filter((a) => a.questionId === "q_potty"), + target: targetOf("entry_meal"), + }, + { + question: baseQuestion({ + _id: "q_outdoor", + entryId: "entry_outdoor", + askedById: "caregiver-nanny", + question: "Is outdoor play okay before pickup?", + createdAt: 1789651800000 + 3, + audienceKind: "directed", + addresseeIds: ["caregiver-dad"], + }), + activities: [], + target: targetOf("entry_outdoor"), + }, +] + +export const CareQuestionsDemo = () => { + const [seeds, setSeeds] = useState(initialSeeds) + const [viewerId, setViewerId] = useState("caregiver-mom") + const [draft, setDraft] = useState("") + + const viewer = useMemo(() => VIEWERS.find((v) => v.id === viewerId) ?? VIEWERS[0], [viewerId]) + if (!viewer) return null + + const principal = { kind: "caregiver", caregiverId: viewer.id, householdIds: viewer.householdIds } as const + const visible = visibleSeeds(seeds, principal) + const digest = digestFor(seeds, { windowStart: 1789617600000 }) + + const appendActivity = (questionId: string, activity: CareQuestionActivityDocument) => { + setSeeds((prev) => + prev.map((seed) => (seed.question._id === questionId ? { ...seed, activities: [...seed.activities, activity] } : seed)), + ) + setDraft("") + } + + const answer = (questionId: string) => { + const text = draft.trim() + if (text.length === 0) return + appendActivity(questionId, { + _id: `local-${questionId}-${Date.now()}`, + _creationTime: Date.now(), + householdId: HOUSEHOLD_A, + childId: CHILD, + questionId, + kind: "answered", + at: Date.now(), + answerText: text, + actorId: viewer.id, + }) + } + + const resolve = (questionId: string) => { + appendActivity(questionId, { + _id: `local-${questionId}-${Date.now()}`, + _creationTime: Date.now(), + householdId: HOUSEHOLD_A, + childId: CHILD, + questionId, + kind: "resolved", + at: Date.now(), + actorId: viewer.id, + }) + } + + const reopen = (questionId: string) => { + appendActivity(questionId, { + _id: `local-${questionId}-${Date.now()}`, + _creationTime: Date.now(), + householdId: HOUSEHOLD_A, + childId: CHILD, + questionId, + kind: "reopened", + at: Date.now(), + reason: "Follow-up needed", + actorId: viewer.id, + }) + } + + return ( + + Caregiver questions + + Prototype: authorization runs through the domain policy; activities stay on-device. + + + + {VIEWERS.map((v) => ( + setViewerId(v.id)} + > + {v.label} + + ))} + + + + Handoff digest + {handoffSummary(digest)} + + + {visible.length === 0 ? ( + No questions are visible for this viewer. + ) : ( + visible.map((seed) => { + const derived = statusLabel(deriveQuestionState(seed.question, seed.activities)) + const canAnswer = viewerCanAnswer(seed, principal, viewer.id) + const canResolve = viewerCanResolve(seed, principal, viewer.id) + const canReopen = viewerCanReopen(seed, principal, viewer.id) + return ( + + {derived} + {seed.question.question} + + {seed.question.audienceKind === "directed" + ? `Directed at ${seed.question.addresseeIds.join(", ")}` + : "Household audience"} + {" · "} + {seed.question.targetKind === "event" ? "event" : "entry"} · {formatEpochTime(seed.question.createdAt)} + + {seed.activities + .filter((a) => a.kind === "answered") + .map((a) => ( + + {a.actorId}: {a.answerText ?? ""} + + ))} + {canAnswer ? : null} + + {canAnswer ? ( + answer(seed.question._id)}> + Answer + + ) : null} + {canResolve ? ( + resolve(seed.question._id)}> + Confirm & resolve + + ) : null} + {canReopen ? ( + reopen(seed.question._id)}> + Reopen + + ) : null} + + + ) + }) + )} + + ) +} + +const styles = StyleSheet.create({ + body: { padding: 16, gap: 12 }, + heading: { fontSize: 20, fontWeight: "700" }, + hint: { fontSize: 12, opacity: 0.7 }, + viewerRow: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, + chip: { borderWidth: 1, borderColor: "#bbb", borderRadius: 16, paddingHorizontal: 12, paddingVertical: 4 }, + chipActive: { backgroundColor: "#333", borderColor: "#333" }, + chipText: { color: "#333" }, + chipTextActive: { color: "#fff" }, + digestCard: { borderWidth: 1, borderColor: "#ddd", borderRadius: 12, padding: 12, gap: 4 }, + digestTitle: { fontWeight: "700" }, + empty: { opacity: 0.7, fontStyle: "italic" }, + card: { borderWidth: 1, borderColor: "#ddd", borderRadius: 12, padding: 12, gap: 6 }, + status: { fontSize: 12, fontWeight: "600", textTransform: "uppercase", opacity: 0.6 }, + question: { fontSize: 16 }, + meta: { fontSize: 12, opacity: 0.6 }, + answer: { fontSize: 14 }, + input: { borderWidth: 1, borderColor: "#bbb", borderRadius: 8, padding: 8 }, + actionRow: { flexDirection: "row", gap: 8 }, + action: { backgroundColor: "#eee", borderRadius: 8, paddingHorizontal: 12, paddingVertical: 6 }, + actionText: { fontSize: 13 }, +}) diff --git a/apps/mobile/src/questions/logic.test.ts b/apps/mobile/src/questions/logic.test.ts new file mode 100644 index 0000000..8978bd0 --- /dev/null +++ b/apps/mobile/src/questions/logic.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" + +import { + CareQuestionActivityDocument, + CareQuestionDocument, + buildHandoffDigest, + type CareQuestionActivityDocument as CareQuestionActivityDocumentType, + type CareQuestionDocument as CareQuestionDocumentType, + type QuestionTargetFacts, +} from "@journal/domain" + +import { + digestFor, + formatEpochTime, + handoffSummary, + statusLabel, + viewerCanAnswer, + viewerCanReopen, + viewerCanResolve, + visibleSeeds, + type QuestionSeed, +} from "./logic" + +/** + * Viewer-logic tests for the questions prototype. Authorization itself is + * pinned by the domain suite (packages/domain/test/careQuestion.test.ts); + * these cover the rendering-relevant derivations on top of it. + */ + +const decodeQuestion = (raw: Record): CareQuestionDocumentType => + Schema.decodeUnknownSync(CareQuestionDocument)(raw) as CareQuestionDocumentType + +const decodeActivity = (raw: Record): CareQuestionActivityDocumentType => + Schema.decodeUnknownSync(CareQuestionActivityDocument)(raw) as CareQuestionActivityDocumentType + +const question = (overrides: Record): CareQuestionDocumentType => + decodeQuestion({ + _id: "q_demo", + _creationTime: 1789651800000, + householdId: "household_a", + childId: "child_a", + entryId: "entry_meal", + targetKind: "entry", + askedById: "caregiver-mom", + audienceKind: "household", + addresseeIds: [], + handoffIncluded: true, + question: "Did she drink water?", + createdAt: 1789651800000, + ...overrides, + }) + +const answered = (questionId: string, actorId: string, text: string, at: number): CareQuestionActivityDocumentType => + decodeActivity({ + _id: `a_${text}`, + _creationTime: at, + householdId: "household_a", + childId: "child_a", + questionId, + kind: "answered", + at, + answerText: text, + actorId, + }) + +const resolved = (questionId: string, actorId: string, at: number): CareQuestionActivityDocumentType => + decodeActivity({ + _id: `r_${questionId}`, + _creationTime: at, + householdId: "household_a", + childId: "child_a", + questionId, + kind: "resolved", + at, + actorId, + }) + +const TARGET: QuestionTargetFacts = { + householdId: "household_a", + childId: "child_a", + authorId: "caregiver-dad", + visibility: "published", +} + +const MOM = { kind: "caregiver", caregiverId: "caregiver-mom", householdIds: ["household_a"] } as const +const DAD = { kind: "caregiver", caregiverId: "caregiver-dad", householdIds: ["household_a"] } as const + +describe("statusLabel", () => { + test("open with no answers says so plainly", () => { + expect(statusLabel({ status: "open", answers: [], reopenedCount: 0, lastActivityAt: 0, handoffIncluded: true })).toBe("No answer yet") + }) + + test("answered states it is awaiting confirmation", () => { + expect(statusLabel({ status: "answered", answers: [], reopenedCount: 0, lastActivityAt: 0, handoffIncluded: true })).toBe( + "Answered — awaiting confirmation", + ) + }) + + test("reopen lineage survives answering and is shown with a count", () => { + expect(statusLabel({ status: "answered", answers: [], reopenedCount: 2, lastActivityAt: 0, handoffIncluded: true })).toBe( + "Answered, reopened ×2", + ) + expect(statusLabel({ status: "open", answers: [], reopenedCount: 1, lastActivityAt: 0, handoffIncluded: true })).toBe( + "reopened ×1 — no answer yet", + ) + }) + + test("resolved is terminal", () => { + expect(statusLabel({ status: "resolved", answers: [], reopenedCount: 0, lastActivityAt: 0, handoffIncluded: true })).toBe("Resolved") + }) +}) + +describe("formatEpochTime", () => { + test("renders deterministic UTC (prototype contract; real screens localize)", () => { + expect(formatEpochTime(1789651800000)).toBe("2026-09-17 13:30 UTC") + }) +}) + +describe("handoffSummary", () => { + test("empty scope says nothing was asked — never 'all clear'", () => { + expect(handoffSummary(buildHandoffDigest([], {}))).toBe( + "No questions were asked in this scope — not the same as all clear.", + ) + }) + + test("counts open, answered-awaiting-confirmation, and resolved lines", () => { + const q = question({ _id: "q_open" }) + const seed: QuestionSeed = { question: q, activities: [answered("q_open", "caregiver-dad", "Two cups.", 1789655400000)], target: TARGET } + const seedResolved: QuestionSeed = { + question: question({ _id: "q_res", askedById: "caregiver-mom" }), + activities: [ + answered("q_res", "caregiver-dad", "None today.", 1789659000000), + resolved("q_res", "caregiver-mom", 1789662600000), + ], + target: TARGET, + } + const digest = digestFor([seed, seedResolved], {}) + expect(handoffSummary(digest)).toBe("2 questions asked: 1 answered, awaiting confirmation, 1 resolved today.") + }) +}) + +describe("visibleSeeds", () => { + test("directed questions reach addressees and the asker, not other household members", () => { + const directed: QuestionSeed = { + question: question({ + _id: "q_dir", + audienceKind: "directed", + addresseeIds: ["caregiver-dad"], + }), + activities: [], + target: TARGET, + } + const open: QuestionSeed = { question: question({ _id: "q_household" }), activities: [], target: TARGET } + // Mom is the asker; dad is the addressee; both see the directed question. + expect(visibleSeeds([directed, open], MOM).map((s) => s.question._id)).toEqual(["q_dir", "q_household"]) + expect(visibleSeeds([directed, open], DAD)).toHaveLength(2) + // Grandma is neither asker nor addressee: only the household-audience question. + const GRANDMA = { kind: "caregiver", caregiverId: "caregiver-grandma", householdIds: ["household_a"] } as const + expect(visibleSeeds([directed, open], GRANDMA).map((s) => s.question._id)).toEqual(["q_household"]) + }) +}) + +describe("action gating", () => { + test("an addressee can answer a directed question; the asker cannot self-answer it", () => { + const directed: QuestionSeed = { + question: question({ _id: "q_dir", audienceKind: "directed", addresseeIds: ["caregiver-dad"] }), + activities: [], + target: TARGET, + } + expect(viewerCanAnswer(directed, DAD, "caregiver-dad")).toBe(true) + expect(viewerCanAnswer(directed, MOM, "caregiver-mom")).toBe(false) + }) + + test("resolution is offered to the viewer once an answer exists; reopen only after resolution", () => { + const seed: QuestionSeed = { + question: question({ _id: "q_flow" }), + activities: [answered("q_flow", "caregiver-dad", "Yes.", 1789655400000)], + target: TARGET, + } + expect(viewerCanResolve(seed, MOM, "caregiver-mom")).toBe(true) + expect(viewerCanReopen(seed, MOM, "caregiver-mom")).toBe(false) + + const resolvedSeed: QuestionSeed = { + question: question({ _id: "q_flow2" }), + activities: [ + answered("q_flow2", "caregiver-dad", "Yes.", 1789655400000), + resolved("q_flow2", "caregiver-mom", 1789659000000), + ], + target: TARGET, + } + expect(viewerCanResolve(resolvedSeed, MOM, "caregiver-mom")).toBe(false) + expect(viewerCanReopen(resolvedSeed, MOM, "caregiver-mom")).toBe(true) + }) +}) diff --git a/apps/mobile/src/questions/logic.ts b/apps/mobile/src/questions/logic.ts new file mode 100644 index 0000000..181f8ca --- /dev/null +++ b/apps/mobile/src/questions/logic.ts @@ -0,0 +1,93 @@ +/** + * Viewer logic for the caregiver-questions prototype: pure functions over the + * domain contracts, so the demo screen stays presentational and the rules stay + * testable without a device. The authorization decisions themselves come from + * `@journal/domain` — nothing here re-implements policy. + * + * Seed data mirrors evaluation/fixtures/agent-experience/caregiver-questions/ + * household-a.json. Real screens will read these documents from Convex. + */ +import { + buildHandoffDigest, + canAnswerQuestion, + canReopenQuestion, + canResolveQuestion, + canViewQuestion, + deriveQuestionState, + type CareQuestionState, + type HandoffDigest, + type QuestionPrincipal, + type QuestionTargetFacts, +} from "@journal/domain" +import type { CareQuestionActivityDocument, CareQuestionDocument } from "@journal/domain" + +export interface QuestionSeed { + readonly question: CareQuestionDocument + readonly activities: readonly CareQuestionActivityDocument[] + readonly target: QuestionTargetFacts +} + +/** Status line shown on a question card — open vs answered vs resolved, with reopen lineage. */ +export const statusLabel = (state: CareQuestionState): string => { + const reopened = state.reopenedCount > 0 ? `reopened ×${state.reopenedCount}` : undefined + if (state.status === "resolved") return "Resolved" + if (state.status === "answered") { + return reopened ? `Answered, ${reopened}` : "Answered — awaiting confirmation" + } + return reopened ? `${reopened} — no answer yet` : "No answer yet" +} + +/** + * Deterministic UTC rendering so prototype output is stable across devices and + * test runs. Real screens will render in the household's timezone instead. + */ +export const formatEpochTime = (at: number): string => { + const iso = new Date(at).toISOString() + return `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC` +} + +/** + * One-line summary for the handoff card. The missing-vs-zero discipline lives + * here too: "no questions" is rendered as an explicit statement, never as + * "all clear". + */ +export const handoffSummary = (digest: HandoffDigest): string => { + if (!digest.hasQuestions) { + return "No questions were asked in this scope — not the same as all clear." + } + const unresolved = digest.unresolved.length + const noAnswer = digest.unresolved.filter((line) => line.answerCount === 0).length + const answered = unresolved - noAnswer + const resolved = digest.resolvedInWindow.length + const total = unresolved + resolved + const parts: string[] = [] + if (noAnswer > 0) parts.push(`${noAnswer} awaiting an answer`) + if (answered > 0) parts.push(`${answered} answered, awaiting confirmation`) + if (resolved > 0) parts.push(`${resolved} resolved today`) + return `${total} question${total === 1 ? "" : "s"} asked: ${parts.join(", ")}.` +} + +const stateOf = (seed: QuestionSeed): CareQuestionState => + deriveQuestionState(seed.question, seed.activities) + +/** Questions the viewer may see at all — invisible ones are excluded, not errors. */ +export const visibleSeeds = (seeds: readonly QuestionSeed[], principal: QuestionPrincipal): QuestionSeed[] => + seeds.filter((seed) => canViewQuestion(principal, seed.question, seed.target).outcome === "ALLOW") + +export const viewerCanAnswer = (seed: QuestionSeed, principal: QuestionPrincipal, caregiverId: string): boolean => + canAnswerQuestion(principal, caregiverId, seed.question, seed.target, stateOf(seed).status).outcome === "ALLOW" + +export const viewerCanResolve = (seed: QuestionSeed, principal: QuestionPrincipal, caregiverId: string): boolean => + canResolveQuestion(principal, caregiverId, seed.question, seed.target, stateOf(seed).status).outcome === "ALLOW" + +export const viewerCanReopen = (seed: QuestionSeed, principal: QuestionPrincipal, caregiverId: string): boolean => + canReopenQuestion(principal, caregiverId, seed.question, seed.target, stateOf(seed).status).outcome === "ALLOW" + +/** Digest over the questions the viewer can see and that are marked for handoff. */ +export const digestFor = (seeds: readonly QuestionSeed[], options?: { readonly windowStart?: number }): HandoffDigest => { + const handoff = seeds.filter((seed) => stateOf(seed).handoffIncluded) + return buildHandoffDigest( + handoff.map((seed) => ({ question: seed.question, activities: seed.activities })), + options, + ) +} diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json index 14d6aaa..d390603 100644 --- a/apps/mobile/tsconfig.json +++ b/apps/mobile/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "noEmit": true, "jsx": "react-jsx", - "types": ["node"] + "types": ["node", "bun"] }, "include": ["index.ts", "App.tsx", "src"] } diff --git a/backend/convex/convex/schema.ts b/backend/convex/convex/schema.ts index 6061291..723f59e 100644 --- a/backend/convex/convex/schema.ts +++ b/backend/convex/convex/schema.ts @@ -1,7 +1,7 @@ import { defineSchema, defineTable } from "convex/server" import { Schema } from "effect" -import { ChildFields, EntryFields, EventFields, HouseholdFields, KnowledgeFields } from "@journal/domain" +import { ChildFields, EntryFields, EventFields, HouseholdFields, KnowledgeFields, CareQuestionFields, CareQuestionActivityFields } from "@journal/domain" import { convexFields } from "@journal/domain/convex" /** @@ -13,6 +13,11 @@ import { convexFields } from "@journal/domain/convex" * source-attributed understanding about the child — settling preferences, * routines, quotes, preferences — separate from logged `events`, append-only * via supersession, audience-free per contract v0.2. + * + * `careQuestions` / `careQuestionActivities` come from the caregiver-questions + * contract proposal (append-only activity lineage; see @journal/domain + * careQuestion.ts). Both derive through the same adapter — no hand-written + * validators here either. */ const tableFrom = (fields: Schema.Struct.Fields) => defineTable(convexFields(Schema.Struct(fields))) @@ -31,4 +36,11 @@ export default defineSchema({ .index("by_household", ["householdId"]) .index("by_child_topic", ["childId", "topic"]) .index("by_supersedes", ["supersedes"]), + careQuestions: tableFrom(CareQuestionFields) + .index("by_household", ["householdId"]) + .index("by_child", ["childId"]) + .index("by_entry", ["entryId"]), + careQuestionActivities: tableFrom(CareQuestionActivityFields) + .index("by_household", ["householdId"]) + .index("by_question", ["questionId"]), }) diff --git a/evaluation/fixtures/agent-experience/caregiver-questions/AREA.md b/evaluation/fixtures/agent-experience/caregiver-questions/AREA.md new file mode 100644 index 0000000..b11e1b6 --- /dev/null +++ b/evaluation/fixtures/agent-experience/caregiver-questions/AREA.md @@ -0,0 +1,64 @@ +# Caregiver-Questions Fixture Area — Lane Descriptor + +This is the caregiver-questions lane's fixture-area descriptor — it is **not** the +harness registration manifest (`evaluation/fixtures/manifest.json`, owned by the +PR #22 harness). This directory is intentionally UNREGISTERED: the corpus runner +only enumerates listed areas, and registration would additionally require the +harness to special-case a nested `manifest.json` (the area file filter excludes +only the root manifest path). If this area is ever registered, do it as +`scenario-and-authorization` / `bun-test-data`, and rename or absorb this file first. + +Consumed directly by `packages/domain/test/careQuestion.test.ts` (fixtureDir URL) — +semantic validation and execution live with that suite, per the +`scenario-and-authorization` class contract. + +```json +{ + "manifestVersion": "0.1.0", + "description": "Caregiver-question lifecycle fixtures: questions attached to journal entries/events, answers, resolution/reopening, directed-vs-household addressing, handoff-digest inclusion, and the missing-vs-zero distinction. Decoded through the canonical Effect schemas on effect@4.0.0-rc.115; policy expectations evaluated against careQuestionPolicy.ts.", + "contract": { + "module": "packages/domain/src/careQuestion.ts", + "effectPin": "4.0.0-rc.115", + "proposesAgainst": "art_I2TCG08V (contract v0.2)" + }, + "fixtures": [ + { + "id": "FQ-HOUSEHOLD-A-LIFECYCLE", + "file": "household-a.json", + "kind": "success", + "exercises": [ + "CareQuestion.attach.entry", + "CareQuestion.attach.event", + "CareQuestion.audienceKind.household", + "CareQuestion.audienceKind.directed", + "CareQuestion.handoffIncluded", + "deriveQuestionState.open", + "deriveQuestionState.answered", + "deriveQuestionState.resolved", + "deriveQuestionState.reopened", + "buildHandoffDigest.explicit-negative", + "buildHandoffDigest.excluded", + "canViewQuestion.addressee-boundary", + "canViewQuestion.draft-target", + "canAskQuestion.roster-boundary", + "canAskQuestion.draft-target" + ] + }, + { + "id": "FQ-HOUSEHOLD-B-ISOLATION", + "file": "household-b.json", + "kind": "success", + "exercises": [ + "canViewQuestion.household-boundary", + "buildHandoffDigest.single-question" + ] + } + ], + "wireRules": [ + "timestamps are epoch-millis numbers (Contract v0.2 wire format)", + "no explicit null anywhere \u2014 absent keys or optionalKey undefined only", + "event-target questions always carry entryId AND eventId (invariant pinned by tests)", + "all ids are 24-character synthetic stand-ins for Convex ids" + ] +} +``` diff --git a/evaluation/fixtures/agent-experience/caregiver-questions/household-a.json b/evaluation/fixtures/agent-experience/caregiver-questions/household-a.json new file mode 100644 index 0000000..691a880 --- /dev/null +++ b/evaluation/fixtures/agent-experience/caregiver-questions/household-a.json @@ -0,0 +1,243 @@ +{ + "id": "FQ-HOUSEHOLD-A-LIFECYCLE", + "description": "One household (mom, dad, grandma, nanny), one child (Leo), 2026-09-17. Six questions across the full lifecycle: open-unanswered, answered-awaiting-resolution, resolved-with-explicit-negative, reopened-after-resolution, draft-attached (author-only visibility), and resolved-but-excluded-from-handoff. Two attempted asks must deny (stranger addressee; ask on another author's draft).", + "household": { + "householdId": "fxhousehold0000000000000", + "memberIds": ["caregiver-mom", "caregiver-dad", "caregiver-grandma", "caregiver-nanny"] + }, + "childId": "fxchildleo00000000000000", + "entries": [ + { "householdId": "fxhousehold0000000000000", "childId": "fxchildleo00000000000000", "_id": "fxentrysleep000000000000", "authorId": "caregiver-mom", "visibility": "published" }, + { "householdId": "fxhousehold0000000000000", "childId": "fxchildleo00000000000000", "_id": "fxentrymeal0000000000000", "authorId": "caregiver-dad", "visibility": "published" }, + { "householdId": "fxhousehold0000000000000", "childId": "fxchildleo00000000000000", "_id": "fxentrydraft000000000000", "authorId": "caregiver-mom", "visibility": "draft" } + ], + "questions": [ + { + "_id": "fxqopenwater000000000000", + "_creationTime": 1789676100000, + "householdId": "fxhousehold0000000000000", + "childId": "fxchildleo00000000000000", + "targetKind": "event", + "entryId": "fxentrymeal0000000000000", + "eventId": "fxeventmeal0000000000000", + "askedById": "caregiver-nanny", + "question": "Did Leo drink water after the playground run?", + "audienceKind": "household", + "addresseeIds": [], + "handoffIncluded": true, + "createdAt": 1789676100000 + }, + { + "_id": "fxqnapcheck0000000000000", + "_creationTime": 1789651800000, + "householdId": "fxhousehold0000000000000", + "childId": "fxchildleo00000000000000", + "targetKind": "event", + "entryId": "fxentrysleep000000000000", + "eventId": "fxeventsleep000000000000", + "askedById": "caregiver-mom", + "question": "How long was the nap really? 45 minutes looks generous.", + "audienceKind": "directed", + "addresseeIds": ["caregiver-dad"], + "handoffIncluded": true, + "sourceCaptureId": "fxentrysleep000000000000", + "createdAt": 1789651800000 + }, + { + "_id": "fxqpottyresolved00000000", + "_creationTime": 1789646400000, + "householdId": "fxhousehold0000000000000", + "childId": "fxchildleo00000000000000", + "targetKind": "entry", + "entryId": "fxentrymeal0000000000000", + "askedById": "caregiver-mom", + "question": "Did he have a bowel movement today?", + "audienceKind": "household", + "addresseeIds": [], + "handoffIncluded": true, + "createdAt": 1789646400000 + }, + { + "_id": "fxqreopen000000000000000", + "_creationTime": 1789666200000, + "householdId": "fxhousehold0000000000000", + "childId": "fxchildleo00000000000000", + "targetKind": "event", + "entryId": "fxentrysleep000000000000", + "eventId": "fxeventsleep000000000000", + "askedById": "caregiver-grandma", + "question": "Was the 2pm sleep a real nap or just drowsy in the stroller?", + "audienceKind": "directed", + "addresseeIds": ["caregiver-mom", "caregiver-dad"], + "handoffIncluded": true, + "createdAt": 1789666200000 + }, + { + "_id": "fxqdraftdeny000000000000", + "_creationTime": 1789644600000, + "householdId": "fxhousehold0000000000000", + "childId": "fxchildleo00000000000000", + "targetKind": "entry", + "entryId": "fxentrydraft000000000000", + "askedById": "caregiver-mom", + "question": "Publish this draft with the rash note, or check with the pediatrician first?", + "audienceKind": "household", + "addresseeIds": [], + "handoffIncluded": false, + "createdAt": 1789644600000 + }, + { + "_id": "fxqexcluded0000000000000", + "_creationTime": 1789653600000, + "householdId": "fxhousehold0000000000000", + "childId": "fxchildleo00000000000000", + "targetKind": "event", + "entryId": "fxentrymeal0000000000000", + "eventId": "fxeventmeal0000000000000", + "askedById": "caregiver-dad", + "question": "Did he refuse the 9am bottle or just want less?", + "audienceKind": "household", + "addresseeIds": [], + "handoffIncluded": false, + "createdAt": 1789653600000 + } + ], + "activities": [ + { + "_id": "fxact00000000000000000001", + "_creationTime": 1789654500000, + "householdId": "fxhousehold0000000000000", + "childId": "fxchildleo00000000000000", + "questionId": "fxqnapcheck0000000000000", + "kind": "answered", + "actorId": "caregiver-dad", + "at": 1789654500000, + "answerText": "About 15 minutes in the car, not 45.", + "sourceKind": "entry", + "sourceId": "fxentrysleep000000000000" + }, + { + "_id": "fxact00000000000000000002", + "_creationTime": 1789669800000, + "householdId": "fxhousehold0000000000000", + "childId": "fxchildleo00000000000000", + "questionId": "fxqpottyresolved00000000", + "kind": "answered", + "actorId": "caregiver-dad", + "at": 1789669800000, + "answerText": "None today." + }, + { + "_id": "fxact00000000000000000003", + "_creationTime": 1789671600000, + "householdId": "fxhousehold0000000000000", + "childId": "fxchildleo00000000000000", + "questionId": "fxqpottyresolved00000000", + "kind": "resolved", + "actorId": "caregiver-mom", + "at": 1789671600000, + "settlesActivityId": "fxact00000000000000000002" + }, + { + "_id": "fxact00000000000000000004", + "_creationTime": 1789668000000, + "householdId": "fxhousehold0000000000000", + "childId": "fxchildleo00000000000000", + "questionId": "fxqreopen000000000000000", + "kind": "answered", + "actorId": "caregiver-dad", + "at": 1789668000000, + "answerText": "Just drowsy in the stroller, no real nap." + }, + { + "_id": "fxact00000000000000000005", + "_creationTime": 1789675200000, + "householdId": "fxhousehold0000000000000", + "childId": "fxchildleo00000000000000", + "questionId": "fxqreopen000000000000000", + "kind": "resolved", + "actorId": "caregiver-grandma", + "at": 1789675200000, + "settlesActivityId": "fxact00000000000000000004" + }, + { + "_id": "fxact00000000000000000006", + "_creationTime": 1789684200000, + "householdId": "fxhousehold0000000000000", + "childId": "fxchildleo00000000000000", + "questionId": "fxqreopen000000000000000", + "kind": "reopened", + "actorId": "caregiver-mom", + "at": 1789684200000, + "reason": "Correction: he did nap 3:15-3:35 in the crib after the stroller." + }, + { + "_id": "fxact00000000000000000007", + "_creationTime": 1789657200000, + "householdId": "fxhousehold0000000000000", + "childId": "fxchildleo00000000000000", + "questionId": "fxqexcluded0000000000000", + "kind": "answered", + "actorId": "caregiver-dad", + "at": 1789657200000, + "answerText": "He refused it - pushed the bottle away twice." + }, + { + "_id": "fxact00000000000000000008", + "_creationTime": 1789659000000, + "householdId": "fxhousehold0000000000000", + "childId": "fxchildleo00000000000000", + "questionId": "fxqexcluded0000000000000", + "kind": "resolved", + "actorId": "caregiver-dad", + "at": 1789659000000, + "settlesActivityId": "fxact00000000000000000007" + } + ], + "expected": { + "derived": [ + { "questionId": "fxqopenwater000000000000", "status": "open", "reopenedCount": 0, "answerCount": 0, "handoffIncluded": true }, + { "questionId": "fxqnapcheck0000000000000", "status": "answered", "reopenedCount": 0, "answerCount": 1, "handoffIncluded": true }, + { "questionId": "fxqpottyresolved00000000", "status": "resolved", "reopenedCount": 0, "answerCount": 1, "handoffIncluded": true }, + { "questionId": "fxqreopen000000000000000", "status": "open", "reopenedCount": 1, "answerCount": 1, "handoffIncluded": true }, + { "questionId": "fxqdraftdeny000000000000", "status": "open", "reopenedCount": 0, "answerCount": 0, "handoffIncluded": false }, + { "questionId": "fxqexcluded0000000000000", "status": "resolved", "reopenedCount": 0, "answerCount": 1, "handoffIncluded": false } + ], + "visibility": [ + { "questionId": "fxqopenwater000000000000", "allow": ["caregiver-mom", "caregiver-dad", "caregiver-grandma", "caregiver-nanny"], "deny": [] }, + { "questionId": "fxqnapcheck0000000000000", "allow": ["caregiver-mom", "caregiver-dad"], "deny": [ { "caregiverId": "caregiver-grandma", "code": "DENY_Q_NOT_ADDRESSEE" }, { "caregiverId": "caregiver-nanny", "code": "DENY_Q_NOT_ADDRESSEE" } ] }, + { "questionId": "fxqpottyresolved00000000", "allow": ["caregiver-mom", "caregiver-dad", "caregiver-grandma", "caregiver-nanny"], "deny": [] }, + { "questionId": "fxqreopen000000000000000", "allow": ["caregiver-mom", "caregiver-dad", "caregiver-grandma"], "deny": [ { "caregiverId": "caregiver-nanny", "code": "DENY_Q_NOT_ADDRESSEE" } ] }, + { "questionId": "fxqdraftdeny000000000000", "allow": ["caregiver-mom"], "deny": [ { "caregiverId": "caregiver-dad", "code": "DENY_Q_DRAFT_AUTHOR_ONLY" }, { "caregiverId": "caregiver-grandma", "code": "DENY_Q_DRAFT_AUTHOR_ONLY" }, { "caregiverId": "caregiver-nanny", "code": "DENY_Q_DRAFT_AUTHOR_ONLY" } ] }, + { "questionId": "fxqexcluded0000000000000", "allow": ["caregiver-mom", "caregiver-dad", "caregiver-grandma", "caregiver-nanny"], "deny": [] } + ], + "askDenies": [ + { + "asCaregiverId": "caregiver-nanny", + "targetEntryId": "fxentrymeal0000000000000", + "audienceKind": "directed", + "addresseeIds": ["caregiver-neighbor"], + "expectedCode": "DENY_Q_ADDRESSEE_NOT_IN_HOUSEHOLD" + }, + { + "asCaregiverId": "caregiver-dad", + "targetEntryId": "fxentrydraft000000000000", + "audienceKind": "household", + "addresseeIds": [], + "expectedCode": "DENY_Q_DRAFT_AUTHOR_ONLY" + } + ], + "digest": { + "note": "digest expectations per viewer; windowStart = local midnight (1789617600000)", + "windowStart": 1789617600000, + "perViewer": [ + { "caregiverId": "caregiver-mom", "hasQuestions": true, "unresolved": ["fxqnapcheck0000000000000", "fxqreopen000000000000000", "fxqopenwater000000000000"], "resolvedInWindow": ["fxqpottyresolved00000000"] }, + { "caregiverId": "caregiver-dad", "hasQuestions": true, "unresolved": ["fxqnapcheck0000000000000", "fxqreopen000000000000000", "fxqopenwater000000000000"], "resolvedInWindow": ["fxqpottyresolved00000000"] }, + { "caregiverId": "caregiver-grandma", "hasQuestions": true, "unresolved": ["fxqreopen000000000000000", "fxqopenwater000000000000"], "resolvedInWindow": ["fxqpottyresolved00000000"] }, + { "caregiverId": "caregiver-nanny", "hasQuestions": true, "unresolved": ["fxqopenwater000000000000"], "resolvedInWindow": ["fxqpottyresolved00000000"] } + ], + "explicitNegativePreserved": { "questionId": "fxqpottyresolved00000000", "latestAnswerText": "None today." }, + "excludedQuestionAbsentFromDigest": "fxqexcluded0000000000000" + } + } +} diff --git a/evaluation/fixtures/agent-experience/caregiver-questions/household-b.json b/evaluation/fixtures/agent-experience/caregiver-questions/household-b.json new file mode 100644 index 0000000..14ac05b --- /dev/null +++ b/evaluation/fixtures/agent-experience/caregiver-questions/household-b.json @@ -0,0 +1,54 @@ +{ + "id": "FQ-HOUSEHOLD-B-ISOLATION", + "description": "Second household proves household scoping: nanny is a member of BOTH households; mom belongs only to household A. Mom has no household path to household-B questions even though she could answer identical questions at home.", + "household": { + "householdId": "fxhouseholdb000000000000", + "memberIds": ["caregiver-nanny", "caregiver-bparent"] + }, + "childId": "fxchildb0000000000000000", + "entries": [ + { "householdId": "fxhouseholdb000000000000", "childId": "fxchildb0000000000000000", "_id": "fxentryb0000000000000000", "authorId": "caregiver-bparent", "visibility": "published" } + ], + "questions": [ + { + "_id": "fxqisolation000000000000", + "_creationTime": 1789670000000, + "householdId": "fxhouseholdb000000000000", + "childId": "fxchildb0000000000000000", + "targetKind": "entry", + "entryId": "fxentryb0000000000000000", + "askedById": "caregiver-nanny", + "question": "Did Maya finish her snack before pickup?", + "audienceKind": "household", + "addresseeIds": [], + "handoffIncluded": true, + "createdAt": 1789670000000 + } + ], + "activities": [], + "expected": { + "derived": [ + { "questionId": "fxqisolation000000000000", "status": "open", "reopenedCount": 0, "answerCount": 0, "handoffIncluded": true } + ], + "visibility": [ + { + "questionId": "fxqisolation000000000000", + "allow": ["caregiver-nanny", "caregiver-bparent"], + "deny": [ + { "caregiverId": "caregiver-mom", "code": "DENY_Q_NO_HOUSEHOLD_PATH" }, + { "caregiverId": "caregiver-dad", "code": "DENY_Q_NO_HOUSEHOLD_PATH" } + ] + } + ], + "askDenies": [], + "digest": { + "note": "digest expectations per viewer; windowStart = local midnight (1789617600000)", + "windowStart": 1789617600000, + "perViewer": [ + { "caregiverId": "caregiver-nanny", "hasQuestions": true, "unresolved": ["fxqisolation000000000000"], "resolvedInWindow": [] }, + { "caregiverId": "caregiver-bparent", "hasQuestions": true, "unresolved": ["fxqisolation000000000000"], "resolvedInWindow": [] }, + { "caregiverId": "caregiver-mom", "hasQuestions": false, "unresolved": [], "resolvedInWindow": [] } + ] + } + } +} diff --git a/packages/domain/src/careQuestion.ts b/packages/domain/src/careQuestion.ts new file mode 100644 index 0000000..b182b5f --- /dev/null +++ b/packages/domain/src/careQuestion.ts @@ -0,0 +1,303 @@ +import { Schema } from "effect" + +import { convexId } from "./ids.js" + +/** + * Caregiver questions attached to journal content — contract proposal for the + * canonical schema (contract v0.2 owner acceptance pending; see the companion + * proposal document and PR description). + * + * Design rules this module obeys: + * + * 1. **Questions attach to existing journal content.** Every question points + * at an `entries` row (and, for event targets, one of its structured + * `events`). There is no standalone "conversation": this is a structured + * Q&A lifecycle over the journal, not general messaging. + * 2. **Audience vs addressing, two dimensions (contract v0.2 rule).** Who a + * question is DIRECTED at is an asking fact stored on the question + * (`audienceKind` + `addresseeIds`, the To: line of the question). Who MAY + * see it is resolved from household/relationship grants plus the target's + * publication state in `./careQuestionPolicy.ts` — never fused into a + * status enum, never widened by publication. + * 3. **Append-only lineage.** Answers, resolutions, reopenings and handoff + * decisions are activity records; nothing mutates a question in place. + * Current state is DERIVED (`deriveQuestionState`) — the same pattern the + * operator-surface design uses for extraction attempts. + * 4. **Raw capture is preserved by reference.** `sourceCaptureId` points at + * the capture/entry the question arose from; the pipeline never copies or + * rewrites source text. + * 5. **Missing is not zero.** An open question with no answers is "awaiting", + * never "none"; a questionless scope is "nothing was asked", never "all + * clear". `buildHandoffDigest` encodes the distinction; tests pin it. + * 6. **Adapter-safe vocabulary only.** Every field below maps through the + * Effect -> Convex adapter (strings, numbers, booleans, literals, unions + * of literals, arrays, nested structs, optional/optionalKey) — no + * `DateFromMillis`, no struct unions. + */ + +/** What a question is attached to. An event target always also names its owning entry. */ +export type CareQuestionTargetKind = "entry" | "event" + +export const CareQuestionFields = { + householdId: convexId("households"), + childId: convexId("children"), + /** `"entry"` = the entry as a whole; `"event"` = one structured event within the entry. */ + targetKind: Schema.Literals(["entry", "event"]), + /** Owning entry — always present, so event targets resolve through the entry. */ + entryId: convexId("entries"), + /** Present if and only if `targetKind === "event"` (invariant pinned by tests). */ + eventId: Schema.optional(convexId("events")), + /** Attribution: the caregiver who asked (external identity string, like `Entry.authorId`). */ + askedById: Schema.String, + /** The question itself, in the asker's words. */ + question: Schema.NonEmptyString, + /** `"directed"` = only the named addressees (+ asker); `"household"` = every household member. */ + audienceKind: Schema.Literals(["directed", "household"]), + /** Addressees for `"directed"` questions; empty (and ignored) for `"household"`. */ + addresseeIds: Schema.Array(Schema.String), + /** Ask-time opt-in to the handoff digest; later decisions arrive as `handoff-marked` activities. */ + handoffIncluded: Schema.Boolean, + /** Provenance: the raw capture/entry this question arose from, when asked via conversation. */ + sourceCaptureId: Schema.optional(Schema.String), + /** Epoch millis (Convex-friendly; wire format is a number). */ + createdAt: Schema.Number, +} satisfies Schema.Struct.Fields + +export const CareQuestionSchema = Schema.Struct(CareQuestionFields) +export type CareQuestion = typeof CareQuestionSchema["Type"] + +/** Question table view plus Convex system fields. */ +export const CareQuestionDocumentFields = { + ...CareQuestionFields, + _id: convexId("careQuestions"), + _creationTime: Schema.Number, +} satisfies Schema.Struct.Fields +export const CareQuestionDocument = Schema.Struct(CareQuestionDocumentFields) +export type CareQuestionDocument = typeof CareQuestionDocument["Type"] + +/** + * Append-only question activity. One row per lifecycle event; the question + * row itself is never updated after creation. + * + * Per-`kind` invariants (optionalKey at rest because the adapter vocabulary + * has no per-kind structs; enforced by tests and the write layer): + * - `answered`: `answerText` REQUIRED; optional answer source (`sourceKind`+`sourceId`) + * attributes where the answer came from — an entry, an event, or a capture. + * - `resolved`: optional `settlesActivityId` names the answer the resolution accepts. + * - `reopened`: `reason` REQUIRED. + * - `handoff-marked`: `handoffIncluded` REQUIRED (the new inclusion state). + */ +export const CareQuestionActivityKind = Schema.Literals(["answered", "resolved", "reopened", "handoff-marked"]) +export type CareQuestionActivityKind = typeof CareQuestionActivityKind["Type"] + +export const CareQuestionActivityFields = { + householdId: convexId("households"), + childId: convexId("children"), + questionId: convexId("careQuestions"), + kind: CareQuestionActivityKind, + /** Attribution: who answered / resolved / reopened / marked. Server-bound at write time. */ + actorId: Schema.String, + /** Epoch millis. */ + at: Schema.Number, + answerText: Schema.optionalKey(Schema.NonEmptyString), + sourceKind: Schema.optionalKey(Schema.Literals(["entry", "event", "capture"])), + sourceId: Schema.optionalKey(Schema.String), + reason: Schema.optionalKey(Schema.NonEmptyString), + settlesActivityId: Schema.optionalKey(Schema.String), + handoffIncluded: Schema.optionalKey(Schema.Boolean), +} satisfies Schema.Struct.Fields + +export const CareQuestionActivitySchema = Schema.Struct(CareQuestionActivityFields) +export type CareQuestionActivity = typeof CareQuestionActivitySchema["Type"] + +/** Activity table view plus Convex system fields. */ +export const CareQuestionActivityDocumentFields = { + ...CareQuestionActivityFields, + _id: convexId("careQuestionActivities"), + _creationTime: Schema.Number, +} satisfies Schema.Struct.Fields +export const CareQuestionActivityDocument = Schema.Struct(CareQuestionActivityDocumentFields) +export type CareQuestionActivityDocument = typeof CareQuestionActivityDocument["Type"] + +// --------------------------------------------------------------------------- +// Derived question state (pure; no storage concerns) +// --------------------------------------------------------------------------- + +export interface CareQuestionAnswerView { + readonly activityId: string + readonly authorId: string + readonly at: number + readonly text: string + readonly source?: { readonly kind: "entry" | "event" | "capture"; readonly id?: string } +} + +export interface CareQuestionState { + /** `"open"` includes reopened questions — reopening returns an answered/resolved question to open. */ + readonly status: "open" | "answered" | "resolved" + readonly reopenedCount: number + readonly answers: readonly CareQuestionAnswerView[] + readonly handoffIncluded: boolean + /** Latest activity time, or the question's `createdAt` when it has none. */ + readonly lastActivityAt: number + readonly resolution?: { + readonly activityId: string + readonly resolvedById: string + readonly at: number + readonly settlesActivityId?: string + } +} + +/** + * Derive the current state of one question from its append-only activity + * stream. Input need not be sorted; ties in `at` preserve input order + * (write order). Answers are keyed by their activity `_id`. + */ +export const deriveQuestionState = ( + question: Pick, + activities: readonly CareQuestionActivityDocument[], +): CareQuestionState => { + const ordered = [...activities].sort((a, b) => a.at - b.at) + + let status: CareQuestionState["status"] = "open" + let reopenedCount = 0 + let handoffIncluded = question.handoffIncluded + let resolution: CareQuestionState["resolution"] + const answers: CareQuestionAnswerView[] = [] + + for (const activity of ordered) { + if (activity.kind === "answered" && activity.answerText !== undefined) { + status = "answered" + answers.push({ + activityId: activity._id, + authorId: activity.actorId, + at: activity.at, + text: activity.answerText, + source: activity.sourceKind === undefined ? undefined : { kind: activity.sourceKind, id: activity.sourceId }, + }) + continue + } + if (activity.kind === "resolved") { + status = "resolved" + resolution = { + activityId: activity._id, + resolvedById: activity.actorId, + at: activity.at, + settlesActivityId: activity.settlesActivityId, + } + continue + } + if (activity.kind === "reopened") { + status = "open" + reopenedCount += 1 + resolution = undefined + continue + } + // kind === "handoff-marked" + if (activity.handoffIncluded !== undefined) { + handoffIncluded = activity.handoffIncluded + } + } + + return { + status, + reopenedCount, + answers, + handoffIncluded, + lastActivityAt: ordered.length > 0 ? (ordered[ordered.length - 1]?.at ?? question.createdAt) : question.createdAt, + resolution, + } +} + +// --------------------------------------------------------------------------- +// Handoff digest (pure; missing-vs-zero distinction is contractual) +// --------------------------------------------------------------------------- + +/** + * Flat, adapter-safe digest line. `kind` separates still-open questions from + * resolutions so a handoff never renders "awaiting" as "none" or an explicit + * negative as a gap. `latestAnswerText` preserves the caregiver's answer + * verbatim — including answers like "None today." (an explicit zero), which + * is a different fact from an unanswered question. + */ +export const HandoffLineSchema = Schema.Struct({ + kind: Schema.Literals(["unresolved", "resolved"]), + // Full document, not the base row: digest lines must identify the question + // (_id) and sort by createdAt without a second lookup. + question: CareQuestionDocument, + status: Schema.Literals(["open", "answered", "resolved"]), + reopenedCount: Schema.Number, + answerCount: Schema.Number, + /** Present when `kind === "resolved"`. */ + resolvedById: Schema.optionalKey(Schema.String), + resolvedAt: Schema.optionalKey(Schema.Number), + latestAnswerText: Schema.optionalKey(Schema.NonEmptyString), + latestAnswerById: Schema.optionalKey(Schema.String), +}) +export type HandoffLine = typeof HandoffLineSchema["Type"] + +export const HandoffDigestSchema = Schema.Struct({ + /** + * False only when NO questions exist in scope at all — "nothing was asked". + * A scope with questions but an empty digest body is a DIFFERENT fact: + * questions exist, none marked for handoff. Callers must not render the + * latter as "all clear". + */ + hasQuestions: Schema.Boolean, + unresolved: Schema.Array(HandoffLineSchema), + resolvedInWindow: Schema.Array(HandoffLineSchema), +}) +export type HandoffDigest = typeof HandoffDigestSchema["Type"] + +export interface HandoffDigestInput { + readonly question: CareQuestionDocument + readonly activities: readonly CareQuestionActivityDocument[] +} + +/** + * Build the handoff digest for a set of questions the CALLER is authorized to + * see (visibility filtering happens in the policy layer before this runs). + * `windowStart` (epoch millis) bounds which resolutions count; unresolved + * questions are unbounded — they stay open until resolved. + */ +export const buildHandoffDigest = ( + inputs: readonly HandoffDigestInput[], + options?: { readonly windowStart?: number }, +): HandoffDigest => { + const windowStart = options?.windowStart + const unresolved: HandoffLine[] = [] + const resolvedInWindow: HandoffLine[] = [] + + for (const { question, activities } of inputs) { + const state = deriveQuestionState(question, activities) + if (!state.handoffIncluded) continue + + const latestAnswer = state.answers[state.answers.length - 1] + // Optional fields are omitted entirely, never set to undefined: the wire + // contract uses optionalKey (absent key), and an explicit-undefined value + // would fail decode and violate exactOptionalPropertyTypes. + const line: HandoffLine = { + kind: state.status === "resolved" ? "resolved" : "unresolved", + question, + status: state.status, + reopenedCount: state.reopenedCount, + answerCount: state.answers.length, + ...(state.resolution + ? { resolvedById: state.resolution.resolvedById, resolvedAt: state.resolution.at } + : {}), + ...(latestAnswer ? { latestAnswerText: latestAnswer.text, latestAnswerById: latestAnswer.authorId } : {}), + } + + if (state.status === "resolved" && state.resolution !== undefined && (windowStart === undefined || state.resolution.at >= windowStart)) { + resolvedInWindow.push(line) + continue + } + if (state.status !== "resolved") { + unresolved.push(line) + } + } + + unresolved.sort((a, b) => a.question.createdAt - b.question.createdAt) + resolvedInWindow.sort((a, b) => (a.resolvedAt ?? 0) - (b.resolvedAt ?? 0)) + + return { hasQuestions: inputs.length > 0, unresolved, resolvedInWindow } +} diff --git a/packages/domain/src/careQuestionPolicy.ts b/packages/domain/src/careQuestionPolicy.ts new file mode 100644 index 0000000..dbac878 --- /dev/null +++ b/packages/domain/src/careQuestionPolicy.ts @@ -0,0 +1,250 @@ +/** + * Fail-closed caregiver-question authorization. + * + * Same discipline as `security/access/policy.ts` (PR #4): pure functions, + * decision VALUES not exceptions, fixed check order, absent or malformed data + * never opens access. Lives in `@journal/domain` (not `security/access`) + * because it composes the CANONICAL schemas; `security/access` still speaks + * the threat-model's mock vocabulary. + * + * The two-dimension rule from contract v0.2, applied to questions: + * - ADDRESSING (who a question is directed at) is an asking fact on the + * question itself (`audienceKind` / `addresseeIds`). + * - VISIBILITY (who may see it) is resolved HERE from (a) household + * membership, (b) the target entry's publication state, and (c) the + * question's addressing — in that fixed order, fail-closed. A directed + * question narrows visibility; nothing widens it. + * + * Membership and the household roster are SERVER-MAINTAINED FACTS passed in + * as parameters — the policy never infers them from a link, a share, or the + * question's own claims. + */ +import type { CareQuestion } from "./careQuestion.js" +import type { EntryVisibility } from "./entry.js" + +export type QuestionPrincipal = + | { readonly kind: "caregiver"; readonly caregiverId: string; readonly householdIds: readonly string[] } + | { readonly kind: "anonymous" } + +export type QuestionDenyCode = + | "DENY_Q_ANONYMOUS" + | "DENY_Q_NO_HOUSEHOLD_PATH" + | "DENY_Q_DRAFT_AUTHOR_ONLY" + | "DENY_Q_NOT_ADDRESSEE" + | "DENY_Q_NOT_ANSWERABLE" + | "DENY_Q_ALREADY_RESOLVED" + | "DENY_Q_NOT_RESOLVED" + | "DENY_Q_ATTRIBUTION_MISMATCH" + | "DENY_Q_ADDRESSEE_NOT_IN_HOUSEHOLD" + | "DENY_Q_INVALID_AUDIENCE" + | "DENY_Q_INVALID_TARGET" + +export type QuestionDecision = + | { readonly outcome: "ALLOW" } + | { readonly outcome: "DENY"; readonly code: QuestionDenyCode; readonly detail: string } + +/** The facts about a question's target entry the policy needs (never inferred). */ +export interface QuestionTargetFacts { + readonly householdId: string + readonly childId: string + readonly authorId: string + readonly visibility: EntryVisibility +} + +export interface AskQuestionChecks { + readonly asAskedById: string + readonly householdId: string + readonly childId: string + readonly targetKind: "entry" | "event" + readonly eventId?: string + readonly audienceKind: "directed" | "household" + readonly addresseeIds: readonly string[] +} + +export type QuestionStatus = "open" | "answered" | "resolved" + +const deny = (code: QuestionDenyCode, detail: string): QuestionDecision => ({ outcome: "DENY", code, detail }) +const ALLOW: QuestionDecision = { outcome: "ALLOW" } + +/** + * Shared read-path checks, in fixed order. The first failing check denies: + * 1. DENY_Q_ANONYMOUS — authentication edge. + * 2. DENY_Q_NO_HOUSEHOLD_PATH — household-membership boundary. + * 3. DENY_Q_DRAFT_AUTHOR_ONLY — publication-state boundary on the TARGET + * entry: drafts are author-only, and a + * question attached to a draft inherits + * that. Membership does not pierce drafts. + * 4. DENY_Q_NOT_ADDRESSEE — addressing boundary for directed questions: + * the asker and the named addressees only. + */ +const readChecks = ( + principal: QuestionPrincipal, + question: Pick, + target: QuestionTargetFacts, +): QuestionDecision => { + if (principal.kind === "anonymous") { + return deny("DENY_Q_ANONYMOUS", "unauthenticated principal cannot see questions") + } + if (!principal.householdIds.includes(target.householdId)) { + return deny( + "DENY_Q_NO_HOUSEHOLD_PATH", + `question target is owned by household ${target.householdId}; caregiver ${principal.caregiverId} holds membership in [${principal.householdIds.join(", ") || "none"}]`, + ) + } + if (target.visibility === "draft" && target.authorId !== principal.caregiverId) { + return deny( + "DENY_Q_DRAFT_AUTHOR_ONLY", + `question is attached to a draft entry authored by ${target.authorId}; caregiver ${principal.caregiverId} is a household member but not the author`, + ) + } + if ( + question.audienceKind === "directed" && + question.askedById !== principal.caregiverId && + !question.addresseeIds.includes(principal.caregiverId) + ) { + return deny( + "DENY_Q_NOT_ADDRESSEE", + `question is directed to [${question.addresseeIds.join(", ") || "no one"}]; caregiver ${principal.caregiverId} is neither the asker nor an addressee`, + ) + } + return ALLOW +} + +/** May this principal SEE this question (and its answers)? */ +export const canViewQuestion = ( + principal: QuestionPrincipal, + question: Pick, + target: QuestionTargetFacts, +): QuestionDecision => readChecks(principal, question, target) + +/** + * May this principal ASK this question? Asking requires reading the target + * (checks 1–3, with the caller's own addressing instead of the question's), + * then target integrity, attribution, and audience validity: + * 5. DENY_Q_INVALID_TARGET — scope mismatch, or an event target without its eventId. + * 6. DENY_Q_ATTRIBUTION_MISMATCH — the ask is attributed to the authenticated principal or nothing. + * 7. DENY_Q_INVALID_AUDIENCE / DENY_Q_ADDRESSEE_NOT_IN_HOUSEHOLD — a directed + * question needs a non-empty addressee list, and every addressee must be + * a server-verified household member (roster passed in, never trusted + * from the request). + */ +export const canAskQuestion = ( + principal: QuestionPrincipal, + input: AskQuestionChecks, + target: QuestionTargetFacts, + householdMemberIds: readonly string[], +): QuestionDecision => { + // The asker must be able to read the target under household addressing — + // their own directed audience is chosen AFTER they prove they can see it. + const read = readChecks( + principal, + { askedById: input.asAskedById, audienceKind: "household", addresseeIds: [] }, + target, + ) + if (read.outcome === "DENY") return read + + if (target.householdId !== input.householdId || target.childId !== input.childId) { + return deny("DENY_Q_INVALID_TARGET", `ask claims household/child ${input.householdId}/${input.childId} but the target entry belongs to ${target.householdId}/${target.childId}`) + } + if (input.targetKind === "event" && (input.eventId === undefined || input.eventId.length === 0)) { + return deny("DENY_Q_INVALID_TARGET", "event-target question is missing its eventId") + } + if (principal.kind === "caregiver" && input.asAskedById !== principal.caregiverId) { + return deny("DENY_Q_ATTRIBUTION_MISMATCH", `ask claims askedById=${input.asAskedById} but authenticated principal is ${principal.caregiverId}`) + } + if (input.audienceKind === "directed") { + if (input.addresseeIds.length === 0) { + return deny("DENY_Q_INVALID_AUDIENCE", "a directed question needs at least one addressee; use audienceKind 'household' instead") + } + const stranger = input.addresseeIds.find((id) => !householdMemberIds.includes(id)) + if (stranger !== undefined) { + return deny( + "DENY_Q_ADDRESSEE_NOT_IN_HOUSEHOLD", + `addressee ${stranger} is not a server-verified member of household ${target.householdId}`, + ) + } + } + return ALLOW +} + +/** + * May this principal ANSWER? Viewers under household addressing may; directed + * questions are answered by their addressees only (the asker resolves instead + * of answering their own directed question). A resolved question must be + * reopened first — answering a settled question would launder the resolution. + */ +export const canAnswerQuestion = ( + principal: QuestionPrincipal, + asActorId: string, + question: CareQuestion, + target: QuestionTargetFacts, + currentStatus: QuestionStatus, +): QuestionDecision => { + const read = readChecks(principal, question, target) + if (read.outcome === "DENY") return read + if (principal.kind === "caregiver" && asActorId !== principal.caregiverId) { + return deny("DENY_Q_ATTRIBUTION_MISMATCH", `answer claims actorId=${asActorId} but authenticated principal is ${principal.caregiverId}`) + } + if (question.audienceKind === "directed" && !question.addresseeIds.includes(asActorId)) { + return deny( + "DENY_Q_NOT_ANSWERABLE", + `question is directed to [${question.addresseeIds.join(", ") || "no one"}]; ${asActorId} may resolve it (if the asker) but not answer it`, + ) + } + if (currentStatus === "resolved") { + return deny("DENY_Q_ALREADY_RESOLVED", "question is resolved; reopen it before answering again") + } + return ALLOW +} + +/** + * May this principal RESOLVE? Any viewer who can see the question may confirm + * it is settled — the family is small, every resolution is attributed with a + * receipt (actor, timestamp, optional answer reference), and reopening is + * always available to reverse it. + */ +export const canResolveQuestion = ( + principal: QuestionPrincipal, + asActorId: string, + question: CareQuestion, + target: QuestionTargetFacts, + currentStatus: QuestionStatus, +): QuestionDecision => { + const read = readChecks(principal, question, target) + if (read.outcome === "DENY") return read + if (principal.kind === "caregiver" && asActorId !== principal.caregiverId) { + return deny("DENY_Q_ATTRIBUTION_MISMATCH", `resolution claims actorId=${asActorId} but authenticated principal is ${principal.caregiverId}`) + } + if (currentStatus === "resolved") { + return deny("DENY_Q_ALREADY_RESOLVED", "question is already resolved; reopen it first if it is not actually settled") + } + return ALLOW +} + +/** May this principal REOPEN? Only a resolved question can be reopened. */ +export const canReopenQuestion = ( + principal: QuestionPrincipal, + asActorId: string, + question: CareQuestion, + target: QuestionTargetFacts, + currentStatus: QuestionStatus, +): QuestionDecision => { + const read = readChecks(principal, question, target) + if (read.outcome === "DENY") return read + if (principal.kind === "caregiver" && asActorId !== principal.caregiverId) { + return deny("DENY_Q_ATTRIBUTION_MISMATCH", `reopen claims actorId=${asActorId} but authenticated principal is ${principal.caregiverId}`) + } + if (currentStatus !== "resolved") { + return deny("DENY_Q_NOT_RESOLVED", `question is ${currentStatus}; only a resolved question can be reopened`) + } + return ALLOW +} + +/** Which of the given questions may this principal see? Invisible = excluded, not an error. */ +export const visibleQuestions = ( + principal: QuestionPrincipal, + questions: readonly { readonly question: CareQuestion; readonly target: QuestionTargetFacts }[], +): CareQuestion[] => + questions + .filter(({ question, target }) => canViewQuestion(principal, question, target).outcome === "ALLOW") + .map(({ question }) => question) diff --git a/packages/domain/src/contracts.ts b/packages/domain/src/contracts.ts index c9b7b38..b143aaf 100644 --- a/packages/domain/src/contracts.ts +++ b/packages/domain/src/contracts.ts @@ -5,6 +5,16 @@ import { EntryFields, EntrySchema } from "./entry.js" import { EventFields } from "./event.js" import { HouseholdFields } from "./household.js" import { CaptureId } from "./extraction.js" +import { + CareQuestionFields, + CareQuestionSchema, + CareQuestionDocument, + CareQuestionActivityFields, + CareQuestionActivityKind, + CareQuestionActivitySchema, + CareQuestionActivityDocument, + HandoffDigestSchema, +} from "./careQuestion.js" /** * Operation contracts for Convex queries/mutations. These are the single @@ -72,3 +82,53 @@ export const AppendEventsInput = Schema.Struct({ events: Schema.Array(Schema.Struct(EventFields)), }) export type AppendEventsInput = typeof AppendEventsInput["Type"] + +// ---- Caregiver questions (Journal & Caregiver Coordination lane) ---- +// Asking is attributed to the acting caregiver and targets an existing entry +// (with its optional event). Audience is directed (explicit addressees) or +// household-wide. The activity contract mirrors CareQuestionActivityKind 1:1; +// payloads are optional because each kind carries a different subset. + +export const AskQuestionInput = Schema.Struct({ + childId: convexId("children"), + entryId: convexId("entries"), + eventId: Schema.optionalKey(convexId("events")), + question: Schema.String, + audienceKind: Schema.Literals(["directed", "household"]), + addresseeIds: Schema.optionalKey(Schema.Array(Schema.String)), + handoffIncluded: Schema.optionalKey(Schema.Boolean), +}) +export type AskQuestionInput = typeof AskQuestionInput["Type"] + +export const AddQuestionActivityInput = Schema.Struct({ + questionId: convexId("careQuestions"), + kind: CareQuestionActivityKind, + answerText: Schema.optionalKey(Schema.String), + sourceKind: Schema.optionalKey(Schema.Literals(["voice", "text", "photo"])), + sourceId: Schema.optionalKey(Schema.String), + reason: Schema.optionalKey(Schema.String), + settlesActivityId: Schema.optionalKey(Schema.String), +}) +export type AddQuestionActivityInput = typeof AddQuestionActivityInput["Type"] + +/** A question plus its append-only activity log; lifecycle state is derived. */ +export const CareQuestionViewSchema = Schema.Struct({ + question: CareQuestionDocument, + activities: Schema.Array(CareQuestionActivityDocument), +}) +export type CareQuestionView = typeof CareQuestionViewSchema["Type"] + +export const ListQuestionsByChildInput = Schema.Struct({ + childId: convexId("children"), + limit: Schema.optionalKey(Schema.Int), +}) +export type ListQuestionsByChildInput = typeof ListQuestionsByChildInput["Type"] + +export const ListQuestionsByChildOutput = Schema.Struct({ + views: Schema.Array(CareQuestionViewSchema), +}) +export type ListQuestionsByChildOutput = typeof ListQuestionsByChildOutput["Type"] + +/** Decode boundary for handoff-digest responses (aliases HandoffDigestSchema). */ +export const HandoffDigestOutput = HandoffDigestSchema +export type HandoffDigestOutput = typeof HandoffDigestOutput["Type"] diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 5ad6437..4c7d991 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -19,6 +19,12 @@ export { CreateHouseholdOutput, ListEntriesByChildInput, ListEntriesByChildOutput, + AddQuestionActivityInput, + AskQuestionInput, + CareQuestionViewSchema, + HandoffDigestOutput, + ListQuestionsByChildInput, + ListQuestionsByChildOutput, } from "./contracts.js" export { AttemptNumber, @@ -65,6 +71,27 @@ export { assertKnowledgeInvariants, } from "./knowledge.js" export { toolSchemaFor } from "./jsonSchema.js" +export { + CareQuestionFields, + CareQuestionSchema, + CareQuestionDocument, + CareQuestionActivityFields, + CareQuestionActivityKind, + CareQuestionActivitySchema, + CareQuestionActivityDocument, + HandoffLineSchema, + HandoffDigestSchema, + buildHandoffDigest, + deriveQuestionState, +} from "./careQuestion.js" +export { + canAskQuestion, + canAnswerQuestion, + canReopenQuestion, + canResolveQuestion, + canViewQuestion, + visibleQuestions, +} from "./careQuestionPolicy.js" export type { Child } from "./child.js" export type { Household } from "./household.js" @@ -73,3 +100,23 @@ export type { Event, EventCategory } from "./event.js" // KnowledgeKind / KnowledgeStatus / KnowledgeProvenanceKind / KnowledgeSourceType // are merged value+type symbols — exported once by value above. export type { Knowledge } from "./knowledge.js" +// Merged schema constants above already export CareQuestionDocument, +// CareQuestionActivity(Document), CareQuestionActivityKind, HandoffLine and +// HandoffDigest with their types; only pure types are re-exported here. +export type { + CareQuestion, + CareQuestionAnswerView, + CareQuestionState, + HandoffLine, + HandoffDigest, + HandoffDigestInput, +} from "./careQuestion.js" +export type { + QuestionPrincipal, + QuestionDenyCode, + QuestionDecision, + QuestionTargetFacts, + AskQuestionChecks, + QuestionStatus, +} from "./careQuestionPolicy.js" +export type { CareQuestionView } from "./contracts.js" diff --git a/packages/domain/test/careQuestion.test.ts b/packages/domain/test/careQuestion.test.ts new file mode 100644 index 0000000..3f236e3 --- /dev/null +++ b/packages/domain/test/careQuestion.test.ts @@ -0,0 +1,539 @@ +import { readFileSync } from "node:fs" +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" + +import { + CareQuestionActivityDocument, + CareQuestionActivitySchema, + CareQuestionDocument, + CareQuestionSchema, + AskQuestionInput, + buildHandoffDigest, + canAnswerQuestion, + canAskQuestion, + canReopenQuestion, + canResolveQuestion, + canViewQuestion, + deriveQuestionState, + HandoffDigestOutput, + visibleQuestions, + type CareQuestionDocument as CareQuestionDocumentType, + type CareQuestionActivityDocument as CareQuestionActivityDocumentType, + type QuestionPrincipal, + type QuestionDenyCode, + type QuestionTargetFacts, +} from "../src/index.js" +import { convexFields } from "../src/convexAdapter.js" + +/** + * Caregiver-question contract tests: fixture-driven decode/derive/policy/digest + * checks plus adapter coverage for the two proposed tables. Fixtures live in + * evaluation/fixtures/agent-experience/caregiver-questions/ and decode through + * the canonical schemas — the fixtures are executable evidence, not prose. + */ + +interface FixtureEntry { + readonly _id: string + readonly authorId: string + readonly visibility: string +} +interface QuestionFixture { + readonly id: string + readonly household: { readonly householdId: string; readonly memberIds: readonly string[] } + readonly childId: string + readonly entries: readonly FixtureEntry[] + readonly questions: readonly unknown[] + readonly activities: readonly unknown[] + readonly expected: { + readonly derived: readonly { + readonly questionId: string + readonly status: "open" | "answered" | "resolved" + readonly reopenedCount: number + readonly answerCount: number + readonly handoffIncluded: boolean + }[] + readonly visibility: readonly { + readonly questionId: string + readonly allow: readonly string[] + readonly deny: readonly { readonly caregiverId: string; readonly code: QuestionDenyCode }[] + }[] + readonly askDenies: readonly { + readonly asCaregiverId: string + readonly targetEntryId: string + readonly audienceKind: "directed" | "household" + readonly addresseeIds: readonly string[] + readonly expectedCode: QuestionDenyCode + }[] + readonly digest: { + readonly windowStart: number + readonly perViewer: readonly { + readonly caregiverId: string + readonly hasQuestions: boolean + readonly unresolved: readonly string[] + readonly resolvedInWindow: readonly string[] + }[] + readonly explicitNegativePreserved?: { readonly questionId: string; readonly latestAnswerText: string } + readonly excludedQuestionAbsentFromDigest?: string + } + } +} + +const fixtureDir = new URL("../../../evaluation/fixtures/agent-experience/caregiver-questions/", import.meta.url) +const loadFixture = (file: string): QuestionFixture => + JSON.parse(readFileSync(new URL(file, fixtureDir), "utf8")) as QuestionFixture + +const decodeQuestion = (raw: unknown): CareQuestionDocumentType => Schema.decodeUnknownSync(CareQuestionDocument)(raw) +const decodeActivity = (raw: unknown): CareQuestionActivityDocumentType => + Schema.decodeUnknownSync(CareQuestionActivityDocument)(raw) + +const HOUSEHOLD_A = "fxhousehold0000000000000" +const HOUSEHOLD_B = "fxhouseholdb000000000000" + +describe("Caregiver-question fixtures decode through the canonical schemas", () => { + const fixtures = [loadFixture("household-a.json"), loadFixture("household-b.json")] + + for (const fixture of fixtures) { + test(`${fixture.id}: every question and activity decodes (wire format, epoch millis)`, () => { + for (const raw of fixture.questions) { + const decoded = decodeQuestion(raw) + expect(typeof decoded.createdAt).toBe("number") + } + for (const raw of fixture.activities) { + const decoded = decodeActivity(raw) + expect(typeof decoded.at).toBe("number") + } + }) + + test(`${fixture.id}: event-target questions always carry entryId AND eventId`, () => { + for (const raw of fixture.questions) { + const question = decodeQuestion(raw) + if (question.targetKind === "event") { + expect(question.eventId).toBeDefined() + expect(question.eventId?.length ?? 0).toBeGreaterThan(0) + } else { + expect(question.eventId).toBeUndefined() + } + } + }) + } + + test("representative question round-trips: decode -> encode -> re-decode is identity", () => { + const fixture = fixtures[0] + if (!fixture) throw new Error("household-a fixture missing") + const question = decodeQuestion(fixture.questions[0]) + const encoded = Schema.encodeSync(CareQuestionDocument)(question) + const redecoded = decodeQuestion(encoded) + expect(redecoded).toEqual(question) + }) +}) + +describe("Lifecycle derivation (deriveQuestionState)", () => { + const fixture = loadFixture("household-a.json") + const questions = fixture.questions.map(decodeQuestion) + const activities = fixture.activities.map(decodeActivity) + const activitiesFor = (questionId: string) => activities.filter((a) => a.questionId === questionId) + const stateOf = (questionId: string) => { + const question = questions.find((q) => q._id === questionId) + if (!question) throw new Error(`unknown question ${questionId}`) + return deriveQuestionState(question, activitiesFor(questionId)) + } + + for (const expected of fixture.expected.derived) { + test(`${expected.questionId}: status=${expected.status} reopened=${expected.reopenedCount} answers=${expected.answerCount}`, () => { + const state = stateOf(expected.questionId) + expect(state.status).toBe(expected.status) + expect(state.reopenedCount).toBe(expected.reopenedCount) + expect(state.answers).toHaveLength(expected.answerCount) + expect(state.handoffIncluded).toBe(expected.handoffIncluded) + }) + } + + test("reopening clears the resolution and returns the question to open (lineage keeps both)", () => { + const state = stateOf("fxqreopen000000000000000") + expect(state.status).toBe("open") + expect(state.reopenedCount).toBe(1) + expect(state.resolution).toBeUndefined() + expect(state.answers).toHaveLength(1) + }) + + test("resolution is a receipt: attributed actor, timestamp, and the answer it settles", () => { + const state = stateOf("fxqpottyresolved00000000") + expect(state.status).toBe("resolved") + expect(state.resolution?.resolvedById).toBe("caregiver-mom") + expect(state.resolution?.settlesActivityId).toBe("fxact00000000000000000002") + }) + + test("unsorted activity input derives identically (sort is defensive)", () => { + const question = questions.find((q) => q._id === "fxqpottyresolved00000000") + if (!question) throw new Error("missing fixture question") + const shuffled = [activities[2], activities[0], activities[1]].filter( + (a): a is CareQuestionActivityDocumentType => a !== undefined && a.questionId === question._id, + ) + const state = deriveQuestionState(question, shuffled) + expect(state.status).toBe("resolved") + expect(state.answers[0]?.text).toBe("None today.") + }) +}) + +describe("Fail-closed question policy (fixture visibility matrix)", () => { + const fixtures = [loadFixture("household-a.json"), loadFixture("household-b.json")] + + const targetOf = (fixture: QuestionFixture, entryId: string): QuestionTargetFacts => { + const entry = fixture.entries.find((e) => e._id === entryId) + if (!entry) throw new Error(`unknown entry ${entryId}`) + return { + householdId: fixture.household.householdId, + childId: fixture.childId, + authorId: entry.authorId, + visibility: entry.visibility === "draft" ? "draft" : "published", + } + } + + const principalFor = (caregiverId: string): QuestionPrincipal => ({ + kind: "caregiver", + caregiverId, + householdIds: fixtures + .filter((f) => f.household.memberIds.includes(caregiverId)) + .map((f) => f.household.householdId), + }) + + for (const fixture of fixtures) { + const questions = fixture.questions.map(decodeQuestion) + + for (const matrix of fixture.expected.visibility) { + test(`${fixture.id}/${matrix.questionId}: visibility matrix`, () => { + const question = questions.find((q) => q._id === matrix.questionId) + if (!question) throw new Error(`unknown question ${matrix.questionId}`) + const target = targetOf(fixture, question.entryId) + + for (const caregiverId of matrix.allow) { + const decision = canViewQuestion(principalFor(caregiverId), question, target) + expect(decision.outcome).toBe("ALLOW") + } + for (const { caregiverId, code } of matrix.deny) { + const decision = canViewQuestion(principalFor(caregiverId), question, target) + expect(decision.outcome).toBe("DENY") + if (decision.outcome === "DENY") expect(decision.code).toBe(code) + } + }) + } + + for (const ask of fixture.expected.askDenies) { + test(`${fixture.id}: ask as ${ask.asCaregiverId} denies with ${ask.expectedCode}`, () => { + const target = targetOf(fixture, ask.targetEntryId) + const decision = canAskQuestion( + principalFor(ask.asCaregiverId), + { + asAskedById: ask.asCaregiverId, + householdId: fixture.household.householdId, + childId: fixture.childId, + targetKind: "entry", + audienceKind: ask.audienceKind, + addresseeIds: ask.addresseeIds, + }, + target, + fixture.household.memberIds, + ) + expect(decision.outcome).toBe("DENY") + if (decision.outcome === "DENY") expect(decision.code).toBe(ask.expectedCode) + }) + } + } + + test("anonymous principal is denied at the authentication edge, before any addressing logic", () => { + const fixture = fixtures[0] + if (!fixture) throw new Error("household-a fixture missing") + const question = decodeQuestion(fixture.questions[0]) + const target = targetOf(fixture, question.entryId) + const decision = canViewQuestion({ kind: "anonymous" }, question, target) + expect(decision.outcome === "DENY" && decision.code).toBe("DENY_Q_ANONYMOUS") + }) + + test("an ask attributed to someone else is an attribution mismatch", () => { + const fixture = fixtures[0] + if (!fixture) throw new Error("household-a fixture missing") + const decision = canAskQuestion( + principalFor("caregiver-mom"), + { + asAskedById: "caregiver-dad", + householdId: fixture.household.householdId, + childId: fixture.childId, + targetKind: "entry", + audienceKind: "household", + addresseeIds: [], + }, + targetOf(fixture, "fxentrymeal0000000000000"), + fixture.household.memberIds, + ) + expect(decision.outcome === "DENY" && decision.code).toBe("DENY_Q_ATTRIBUTION_MISMATCH") + }) + + test("event-target ask without eventId and cross-household ask are invalid targets", () => { + const fixture = fixtures[0] + if (!fixture) throw new Error("household-a fixture missing") + const mom = principalFor("caregiver-mom") + const noEvent = canAskQuestion( + mom, + { + asAskedById: "caregiver-mom", + householdId: fixture.household.householdId, + childId: fixture.childId, + targetKind: "event", + audienceKind: "household", + addresseeIds: [], + }, + targetOf(fixture, "fxentrysleep000000000000"), + fixture.household.memberIds, + ) + expect(noEvent.outcome === "DENY" && noEvent.code).toBe("DENY_Q_INVALID_TARGET") + + const wrongHousehold = canAskQuestion( + mom, + { + asAskedById: "caregiver-mom", + householdId: HOUSEHOLD_B, + childId: fixture.childId, + targetKind: "entry", + audienceKind: "household", + addresseeIds: [], + }, + targetOf(fixture, "fxentrymeal0000000000000"), + fixture.household.memberIds, + ) + expect(wrongHousehold.outcome === "DENY" && wrongHousehold.code).toBe("DENY_Q_INVALID_TARGET") + }) + + test("directed ask with empty addressee list is an invalid audience", () => { + const fixture = fixtures[0] + if (!fixture) throw new Error("household-a fixture missing") + const decision = canAskQuestion( + principalFor("caregiver-mom"), + { + asAskedById: "caregiver-mom", + householdId: fixture.household.householdId, + childId: fixture.childId, + targetKind: "entry", + audienceKind: "directed", + addresseeIds: [], + }, + targetOf(fixture, "fxentrymeal0000000000000"), + fixture.household.memberIds, + ) + expect(decision.outcome === "DENY" && decision.code).toBe("DENY_Q_INVALID_AUDIENCE") + }) + + test("lifecycle edges: no answers under resolution, no reopen unless resolved, no asker-self-answer on directed questions", () => { + const fixture = fixtures[0] + if (!fixture) throw new Error("household-a fixture missing") + const questions = fixture.questions.map(decodeQuestion) + const activities = fixture.activities.map(decodeActivity) + const target = targetOf(fixture, "fxentrymeal0000000000000") + + const byId = (id: string) => { + const q = questions.find((x) => x._id === id) + if (!q) throw new Error(`unknown question ${id}`) + return q + } + const statusOf = (id: string) => + deriveQuestionState(byId(id), activities.filter((a) => a.questionId === id)).status + + // Answering a resolved question would launder the resolution. + const answerResolved = canAnswerQuestion( + principalFor("caregiver-dad"), + "caregiver-dad", + byId("fxqpottyresolved00000000"), + target, + statusOf("fxqpottyresolved00000000"), + ) + expect(answerResolved.outcome === "DENY" && answerResolved.code).toBe("DENY_Q_ALREADY_RESOLVED") + + // The asker of a DIRECTED question may not answer it themselves — they resolve. + const selfAnswer = canAnswerQuestion( + principalFor("caregiver-mom"), + "caregiver-mom", + byId("fxqnapcheck0000000000000"), + targetOf(fixture, "fxentrysleep000000000000"), + statusOf("fxqnapcheck0000000000000"), + ) + expect(selfAnswer.outcome === "DENY" && selfAnswer.code).toBe("DENY_Q_NOT_ANSWERABLE") + + // Reopening an open question is a no-op attempt; reopening a resolved one is allowed. + const reopenOpen = canReopenQuestion( + principalFor("caregiver-mom"), + "caregiver-mom", + byId("fxqopenwater000000000000"), + target, + statusOf("fxqopenwater000000000000"), + ) + expect(reopenOpen.outcome === "DENY" && reopenOpen.code).toBe("DENY_Q_NOT_RESOLVED") + + const reopenResolved = canReopenQuestion( + principalFor("caregiver-mom"), + "caregiver-mom", + byId("fxqexcluded0000000000000"), + target, + statusOf("fxqexcluded0000000000000"), + ) + expect(reopenResolved.outcome).toBe("ALLOW") + + // Resolving an already-resolved question denies. + const reResolve = canResolveQuestion( + principalFor("caregiver-mom"), + "caregiver-mom", + byId("fxqpottyresolved00000000"), + target, + statusOf("fxqpottyresolved00000000"), + ) + expect(reResolve.outcome === "DENY" && reResolve.code).toBe("DENY_Q_ALREADY_RESOLVED") + }) + + test("visibleQuestions projection: invisible questions are excluded, not errors", () => { + const fixture = fixtures[0] + if (!fixture) throw new Error("household-a fixture missing") + const questions = fixture.questions.map(decodeQuestion) + const pairs = questions.map((question) => ({ + question, + target: targetOf(fixture, question.entryId), + })) + expect(visibleQuestions(principalFor("caregiver-grandma"), pairs)).toHaveLength(4) + expect(visibleQuestions(principalFor("caregiver-mom"), pairs)).toHaveLength(6) + }) +}) + +describe("Handoff digest (missing-vs-zero discipline)", () => { + const fixtures = [loadFixture("household-a.json"), loadFixture("household-b.json")] + + const digestFor = (fixture: QuestionFixture, caregiverId: string) => { + const principal: QuestionPrincipal = { + kind: "caregiver", + caregiverId, + householdIds: fixtures + .filter((f) => f.household.memberIds.includes(caregiverId)) + .map((f) => f.household.householdId), + } + const questions = fixture.questions.map(decodeQuestion) + const activities = fixture.activities.map(decodeActivity) + const targetFor = (entryId: string) => { + const entry = fixture.entries.find((e) => e._id === entryId) + if (!entry) throw new Error(`unknown entry ${entryId}`) + return { + householdId: fixture.household.householdId, + childId: fixture.childId, + authorId: entry.authorId, + visibility: entry.visibility === "draft" ? ("draft" as const) : ("published" as const), + } + } + const inputs = questions + .filter((q) => canViewQuestion(principal, q, targetFor(q.entryId)).outcome === "ALLOW") + .map((q) => ({ question: q, activities: activities.filter((a) => a.questionId === q._id) })) + return buildHandoffDigest(inputs, { windowStart: fixture.expected.digest.windowStart }) + } + + for (const fixture of fixtures) { + for (const expected of fixture.expected.digest.perViewer) { + test(`${fixture.id}: digest for ${expected.caregiverId}`, () => { + const digest = digestFor(fixture, expected.caregiverId) + expect(digest.hasQuestions).toBe(expected.hasQuestions) + expect(digest.unresolved.map((l) => l.question._id)).toEqual([...expected.unresolved]) + expect(digest.resolvedInWindow.map((l) => l.question._id)).toEqual([...expected.resolvedInWindow]) + }) + } + } + + test("an explicit negative answer survives into the digest verbatim — it is a fact, not a gap", () => { + const fixture = fixtures[0] + if (!fixture) throw new Error("household-a fixture missing") + const digest = digestFor(fixture, "caregiver-mom") + const resolved = digest.resolvedInWindow[0] + expect(resolved?.latestAnswerText).toBe("None today.") + expect(resolved?.latestAnswerById).toBe("caregiver-dad") + expect(resolved?.resolvedById).toBe("caregiver-mom") + }) + + test("a question excluded from handoff is absent from the digest body but still counted as asked", () => { + const fixture = fixtures[0] + if (!fixture) throw new Error("household-a fixture missing") + const digest = digestFor(fixture, "caregiver-mom") + const excluded = fixture.expected.digest.excludedQuestionAbsentFromDigest + if (!excluded) throw new Error("fixture missing excluded id") + expect(digest.hasQuestions).toBe(true) + expect(digest.unresolved.map((l) => l.question._id)).not.toContain(excluded) + expect(digest.resolvedInWindow.map((l) => l.question._id)).not.toContain(excluded) + }) + + test("empty scope: hasQuestions=false — nothing was asked, which is not 'all clear'", () => { + const digest = buildHandoffDigest([], { windowStart: 0 }) + expect(digest.hasQuestions).toBe(false) + expect(digest.unresolved).toHaveLength(0) + expect(digest.resolvedInWindow).toHaveLength(0) + }) + + test("digest output decodes through its own contract schema", () => { + const fixture = fixtures[0] + if (!fixture) throw new Error("household-a fixture missing") + const digest = digestFor(fixture, "caregiver-mom") + expect(() => Schema.decodeUnknownSync(HandoffDigestOutput)(digest)).not.toThrow() + }) +}) + +describe("Effect -> Convex adapter coverage for the proposed tables", () => { + test("careQuestions fields derive with id-annotated references and optional marker", () => { + const fields = convexFields(CareQuestionSchema) + + const householdId = fields.householdId as { kind: string; tableName: string } + expect(householdId).toMatchObject({ kind: "id", tableName: "households" }) + + const entryId = fields.entryId as { kind: string; tableName: string } + expect(entryId).toMatchObject({ kind: "id", tableName: "entries" }) + + const eventId = fields.eventId as { kind: string; tableName: string; isOptional: string } + expect(eventId.kind).toBe("id") + expect(eventId.tableName).toBe("events") + expect(eventId.isOptional).toBe("optional") + + const targetKind = fields.targetKind as { kind: string; members: unknown[] } + expect(targetKind.kind).toBe("union") + expect(targetKind.members).toHaveLength(2) + + const audienceKind = fields.audienceKind as { kind: string; members: unknown[] } + expect(audienceKind.kind).toBe("union") + expect(audienceKind.members).toHaveLength(2) + + const addresseeIds = fields.addresseeIds as { kind: string } + expect(addresseeIds.kind).toBe("array") + + const handoffIncluded = fields.handoffIncluded as { kind: string; isOptional: string } + expect(handoffIncluded.kind).toBe("boolean") + expect(handoffIncluded.isOptional).toBe("required") + }) + + test("careQuestionActivities fields derive with per-kind optional payloads", () => { + const fields = convexFields(CareQuestionActivitySchema) + + const questionId = fields.questionId as { kind: string; tableName: string } + expect(questionId).toMatchObject({ kind: "id", tableName: "careQuestions" }) + + const kind = fields.kind as { kind: string; members: unknown[] } + expect(kind.kind).toBe("union") + expect(kind.members).toHaveLength(4) + + for (const optionalField of ["answerText", "sourceKind", "sourceId", "reason", "settlesActivityId", "handoffIncluded"]) { + const field = fields[optionalField] as { isOptional: string } | undefined + expect(field?.isOptional).toBe("optional") + } + + const at = fields.at as { kind: string; isOptional: string } + expect(at.kind).toBe("float64") + expect(at.isOptional).toBe("required") + }) + + test("operation-contract args derive (AskQuestionInput)", () => { + const fields = convexFields(AskQuestionInput) + const entryId = fields.entryId as { kind: string; tableName: string } + expect(entryId).toMatchObject({ kind: "id", tableName: "entries" }) + const eventId = fields.eventId as { isOptional: string } + expect(eventId.isOptional).toBe("optional") + const handoffIncluded = fields.handoffIncluded as { isOptional: string } + expect(handoffIncluded.isOptional).toBe("optional") + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28d2f47..ae7e642 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: apps/mobile: dependencies: + '@journal/domain': + specifier: workspace:* + version: link:../../packages/domain '@journal/ui': specifier: workspace:* version: link:../../packages/ui @@ -36,6 +39,9 @@ importers: specifier: 0.86.3 version: 0.86.3(@babel/core@7.29.7)(@types/react@19.2.18)(react@19.2.3) devDependencies: + '@types/bun': + specifier: ^1.3.14 + version: 1.4.2 '@types/node': specifier: ^20.19.0 version: 20.19.43