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