diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 4fad898e2..ea7b675fb 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -1765,10 +1765,15 @@ paths:
format: binary
"401": *a1
"403":
- description: "Refused: the request was made while the session is acting on another account (`meta.errorCode` =
+ description: >-
+ The Bearer token carries a narrow scope. The encrypted archive needs a cookie session or a full-access
+ token; the refusal happens before any second-factor proof is looked at.
+
+
+ Refused: the request was made while the session is acting on another account (`meta.errorCode` =
`sharing.not_permitted`). This operation resolves the CALLER and never a named record, so under a switch it
refuses instead of quietly answering with the caller's own — leave the shared record first. No grant at any
- level opens it."
+ level opens it.
content:
application/json:
schema:
diff --git a/docs/self-hosting/account-sharing.md b/docs/self-hosting/account-sharing.md
index c05814d85..05e2a9b14 100644
--- a/docs/self-hosting/account-sharing.md
+++ b/docs/self-hosting/account-sharing.md
@@ -84,11 +84,11 @@ Three levels, fixed when the invitation is written. Raising one means
revoking and inviting again, because widening a grant in place would carry
a consent the other person never gave.
-| Level | Adds |
-| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Read | Read what the grant opens, and change nothing. |
-| Write | Add entries: a reading, a lab result, an analyte, an illness entry, a side effect, a medication, marking a dose taken or skipped, a visit, a practice, a vaccination dose and the booster reminder it suggests. Editing, deleting and restoring stay with you. |
-| Manage | Change and remove entries, including ones the owner wrote; record the health background; read the insights generated from the record. Always the entire record. |
+| Level | Adds |
+| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Read | Read what the grant opens, and change nothing. |
+| Write | Add entries: a reading, a lab result, an analyte, an illness entry, a side effect, a medication, marking a dose taken or skipped, a visit, a practice, and a vaccination dose. Editing, deleting and restoring stay with you. |
+| Manage | Change and remove entries, including ones the owner wrote; record the health background; read the insights generated from the record. Always the entire record. |
Some entries are creates only **manage** reaches, and the split runs by
entry rather than by section: an allergy, a family-history entry, a mood
@@ -96,6 +96,13 @@ entry, a screener, a cycle day, a water entry and a preventive-care
reminder are all created at manage, in sections a write grant can
otherwise add to. Nothing in the document vault is writable at any level.
+One entry needs two sections rather than a higher level. Planning the
+booster a vaccination suggests files it as a preventive-care reminder,
+which lives under Readings, so a grant that opens Health background alone
+logs the dose and is refused the booster. Logging the dose is unaffected
+either way, and the app does not offer the booster to a grant that cannot
+plan it.
+
Offering **manage** asks the owner for a fresh second factor if they have
one enrolled, which makes it a browser-only act either way: a Bearer
caller is refused before anything else happens and told to use a browser.
diff --git a/src/app/api/cycle/calendar/route.ts b/src/app/api/cycle/calendar/route.ts
index 2c69eb869..3ae86a992 100644
--- a/src/app/api/cycle/calendar/route.ts
+++ b/src/app/api/cycle/calendar/route.ts
@@ -111,6 +111,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
// Apple Watch wrist/skin temperature feeds the temperature-trend
// ovulation layer. Read the WRIST_TEMPERATURE measurements as nightly
// values; the engine derives the trailing-mean deviation itself.
+ //
+ // This is a measurements read on a route declared under `cycle`, and it
+ // needs no section predicate for one reason: no value from it reaches the
+ // response. It goes into `predictCycle` and what comes out the other side
+ // is a shifted date. The crosstab next door reads the same channel and IS
+ // fenced, because there the values are the answer.
prisma.measurement.findMany({
where: {
userId: user.id,
diff --git a/src/components/__tests__/delegated-write-affordances.test.tsx b/src/components/__tests__/delegated-write-affordances.test.tsx
index 7b7aacc36..de7a48835 100644
--- a/src/components/__tests__/delegated-write-affordances.test.tsx
+++ b/src/components/__tests__/delegated-write-affordances.test.tsx
@@ -714,11 +714,15 @@ describe("the immunization log's add, edit and delete", () => {
});
};
+ /** The row carries the edit affordance only while it opens something. */
+ const rowIsTappable = (html: string) =>
+ html.includes('data-slot-open="vaccination-row-open"');
+
it("offers all three in the caller's own record", () => {
const html = render(OWN_RECORD, , seedList);
expect(html).toContain('data-slot="vaccination-add"');
// The row opens the edit sheet, which is where the delete lives.
- expect(html).toContain('role="button"');
+ expect(rowIsTappable(html)).toBe(true);
});
it("offers a read-only delegate none of them", () => {
@@ -730,7 +734,7 @@ describe("the immunization log's add, edit and delete", () => {
seedList,
);
expect(html).not.toContain('data-slot="vaccination-add"');
- expect(html).not.toContain('role="button"');
+ expect(rowIsTappable(html)).toBe(false);
});
it("offers a WRITE delegate the add and not the row", () => {
@@ -740,8 +744,8 @@ describe("the immunization log's add, edit and delete", () => {
seedList,
);
expect(html).toContain('data-slot="vaccination-add"');
- expect(html, "editing and deleting a dose are MANAGE").not.toContain(
- 'role="button"',
+ expect(rowIsTappable(html), "editing and deleting a dose are MANAGE").toBe(
+ false,
);
});
@@ -752,7 +756,7 @@ describe("the immunization log's add, edit and delete", () => {
seedList,
);
expect(html).toContain('data-slot="vaccination-add"');
- expect(html).toContain('role="button"');
+ expect(rowIsTappable(html)).toBe(true);
});
it("offers a MANAGE delegate outside the section neither", () => {
@@ -764,7 +768,7 @@ describe("the immunization log's add, edit and delete", () => {
seedList,
);
expect(html).not.toContain('data-slot="vaccination-add"');
- expect(html).not.toContain('role="button"');
+ expect(rowIsTappable(html)).toBe(false);
});
it("offers the empty state's add on the same rule", () => {
diff --git a/src/components/encounters/encounter-sheet.tsx b/src/components/encounters/encounter-sheet.tsx
index 26e2a8d0e..abac9c3fb 100644
--- a/src/components/encounters/encounter-sheet.tsx
+++ b/src/components/encounters/encounter-sheet.tsx
@@ -20,6 +20,16 @@
* saved and a plain sentence saying the checkup stayed open. That is reported
* rather than skipped in silence: somebody who believes a checkup was closed
* will not look at it again.
+ *
+ * Capability note: this sheet carries no capability check of its own, by
+ * decision. Save is a create on one mount and an edit on the other, and Delete
+ * exists only in edit mode — so the question belongs to whoever opens it.
+ * `VisitsSection` hands it a record only under `canManageDomain("profile")`,
+ * and the two mounts that can only create (`EpisodeVisitsCard`,
+ * `VorsorgeSection`) pass `encounter={null}` behind their own write check. A
+ * THIRD mount that hands this sheet a record without asking the manage
+ * question reintroduces an ungated Delete, and nothing here will fail. Ask at
+ * the caller, or move the check in here for everyone.
*/
import { useState } from "react";
import { Trash2 } from "lucide-react";
diff --git a/src/components/layout/capture-picker.tsx b/src/components/layout/capture-picker.tsx
index 6232e6f3d..b3415e891 100644
--- a/src/components/layout/capture-picker.tsx
+++ b/src/components/layout/capture-picker.tsx
@@ -58,9 +58,11 @@ type CaptureKind = "measurement" | "medication" | "mood";
* offered whenever the grant could write anywhere — and that answer is the
* grant's LEVEL with no scope term at all. A WRITE grant scoped to `["labs"]`
* read as true and got a weight form the server refuses; one scoped to
- * `["documents"]`, which takes no delegated write at any level, got both. `writableDomains` is already the level × scope × route-table
- * intersection, so asking it per section answers both cases and the mood
- * entry's MANAGE-only create as well.
+ * `["documents"]`, which takes no delegated write at any level, got both.
+ *
+ * `writableDomains` is already the level × scope × route-table intersection,
+ * so asking it per section answers both cases and the mood entry's
+ * MANAGE-only create as well.
*/
const CAPTURE_KIND_DOMAIN: Readonly> = {
measurement: "measurements",
diff --git a/src/components/vaccinations/vaccination-list.tsx b/src/components/vaccinations/vaccination-list.tsx
index 7974b135c..442803175 100644
--- a/src/components/vaccinations/vaccination-list.tsx
+++ b/src/components/vaccinations/vaccination-list.tsx
@@ -157,6 +157,10 @@ function DoseRow({
` replaced the card.
+ data-slot-open={onEdit ? "vaccination-row-open" : undefined}
data-vaccination-id={record.id}
onClick={onEdit ? () => onEdit(record) : undefined}
role={onEdit ? "button" : undefined}
diff --git a/src/components/vaccinations/vaccination-sheet.tsx b/src/components/vaccinations/vaccination-sheet.tsx
index a7c6c2022..d8c83951f 100644
--- a/src/components/vaccinations/vaccination-sheet.tsx
+++ b/src/components/vaccinations/vaccination-sheet.tsx
@@ -10,6 +10,13 @@
* On a successful create the caller may raise the booster-mint prompt; this
* sheet completes and closes regardless of what the person does with it. The
* mint is an offer, never a step.
+ *
+ * Capability note: the same posture as the visit sheet. No check here; Delete
+ * renders only in edit mode, and the one mount that can reach edit mode
+ * (`VaccinationsView`) asks `canManageDomain("profile")` before handing over a
+ * record, while its create path asks `canWriteDomain("profile")`. A second
+ * mount that skips either question reintroduces an ungated control with
+ * nothing failing.
*/
import { useState } from "react";
import { Trash2 } from "lucide-react";
diff --git a/src/lib/openapi/routes/export.ts b/src/lib/openapi/routes/export.ts
index 3cf954223..dfd157c28 100644
--- a/src/lib/openapi/routes/export.ts
+++ b/src/lib/openapi/routes/export.ts
@@ -310,7 +310,9 @@ export const exportPaths: NonNullable = {
},
},
"403": {
- description: SHARING_NOT_PERMITTED_DESCRIPTION,
+ description:
+ "The Bearer token carries a narrow scope. The encrypted archive needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.\n\n" +
+ SHARING_NOT_PERMITTED_DESCRIPTION,
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
diff --git a/tests/integration/cycle-insights-scoped-delegate.test.ts b/tests/integration/cycle-insights-scoped-delegate.test.ts
index da1cf82d9..482afc6f5 100644
--- a/tests/integration/cycle-insights-scoped-delegate.test.ts
+++ b/tests/integration/cycle-insights-scoped-delegate.test.ts
@@ -165,6 +165,29 @@ async function seedRecord() {
today,
);
+ // One catalogue symptom, logged on enough phase-labelled days to clear the
+ // pattern floor. Without it `symptomPatterns` is empty in every leg and the
+ // "the cycle-native answer still arrives" property could not be shown even
+ // by a leg that asserted it.
+ //
+ // Upserted rather than created: the symptom catalogue is seeded by migration
+ // and survives the per-test truncation, so a plain create passes on the
+ // first leg and collides on the second.
+ const category = await prisma.cycleSymptomCategory.upsert({
+ where: { key: "cycle-insights-cat" },
+ update: {},
+ create: { key: "cycle-insights-cat", labelKey: "cycle.category.test" },
+ });
+ const symptom = await prisma.cycleSymptom.upsert({
+ where: { key: "cycle-insights-cramps" },
+ update: {},
+ create: {
+ categoryId: category.id,
+ key: "cycle-insights-cramps",
+ labelKey: "cycle.symptom.cramps",
+ },
+ });
+
let luteal = 0;
let follicular = 0;
for (const [day, phase] of phaseByDay) {
@@ -195,6 +218,18 @@ async function seedRecord() {
tz: "UTC",
},
});
+ // Cycle-native data, logged on the luteal days only so the pattern has a
+ // dominant phase to report.
+ if (high) {
+ await prisma.cycleDayLog.create({
+ data: {
+ userId: OWNER_ID,
+ date: day,
+ tz: "UTC",
+ symptomLinks: { create: [{ symptomId: symptom.id }] },
+ },
+ });
+ }
}
expect(
@@ -211,7 +246,7 @@ interface InsightsBody {
rows: Array<{ metricKey: string }>;
headline: { metricKey: string } | null;
lagged: { discovered: unknown[] };
- symptomPatterns: unknown[];
+ symptomPatterns: Array<{ symptomKey: string; topPhase: string }>;
}
async function readInsights(): Promise {
@@ -243,6 +278,10 @@ describe("cycle insights fence the sections it reads across", () => {
"weight",
);
expect(keys, "and the mood contrast beside it").toContain("mood");
+ expect(
+ body.symptomPatterns.map((row) => row.symptomKey),
+ "and the cycle-native patterns the fence must not touch",
+ ).toContain("cycle-insights-cramps");
});
it("gives an entire-record delegate the same figures", async () => {
@@ -267,6 +306,13 @@ describe("cycle insights fence the sections it reads across", () => {
body.lagged.discovered,
"the lagged matrix is built from the same two sections",
).toHaveLength(0);
+ // The other half of the property, and the one a blanket refusal would
+ // break: the symptom-by-phase patterns are cycle data and the grant opens
+ // cycle, so they arrive in full.
+ expect(
+ body.symptomPatterns.map((row) => row.symptomKey),
+ "the cycle-native answer still arrives",
+ ).toContain("cycle-insights-cramps");
});
it("gives a cycle+mind delegate the mood figure and not the weight", async () => {