Publish share-selection group membership in capabilities

GET /api/meta/capabilities dropped group membership at the wire: it
shipped a flat group-id list and a flat leaf list with no boundary
between them, so a native client could not tell which leaves live
under which group, or which group is the fenced sensitive tier. That
made it unsafe for the client to offer a "select all" affordance for
any group, since it could not mirror the server's fence against
bundling more than one sensitive leaf behind a single control.

Add SHARE_GROUPS to the report-selection catalogue, derived from the
existing REPORT_GROUPS/MEASUREMENT_LEAF_GROUP/STRUCTURED_LEAF_GROUP
maps: each group now carries its member leaf ids, and the group whose
id equals SENSITIVE_GROUP_ID carries sensitive: true. share.groups in
the capabilities response now ships this shape instead of bare ids;
share.leaves is unchanged. Update the OpenAPI schema and regenerate
docs/api/openapi.yaml.

Add an invariant test asserting every leaf in the flat leaves set
appears in exactly one group and no group carries a leaf outside that
set, plus a check that exactly one group is flagged sensitive.
This commit is contained in:
Marc-André Bombeck committed 2026-07-30 19:54:49 +02:00
1 parent b767cff187
commit c9c824ca4d
6 files changed
+139 -11

No files matched your search

+18 -2
View File
@@ -16096,8 +16096,24 @@ components:
groups:
type: array
items:
type: string
description: Selection group ids, in presentation order.
type: object
properties:
id:
type: string
description: Group id.
leaves:
type: array
items:
type: string
description: Member leaf ids, in the order the panel renders them.
sensitive:
description: "Present and true only on the fenced tier: no single control may switch on more than one of its leaves."
type: boolean
required:
- id
- leaves
additionalProperties: false
description: Selection groups, in presentation order, each carrying its member leaf ids.
leaves:
type: array
items:
@@ -61,6 +61,7 @@ import { SHARE_LINK_MAX_DAYS } from "@/lib/validations/clinician-share-link";
import {
ALL_LEAF_IDS,
REPORT_GROUP_ORDER,
SENSITIVE_GROUP_ID,
} from "@/lib/report-selection/catalogue";
const SESSION_OK = {
@@ -96,7 +97,7 @@ type CapabilitiesBody = {
maxDays: number;
reportDownload: string[];
selectionVersion: number;
groups: string[];
groups: { id: string; leaves: string[]; sensitive?: boolean }[];
leaves: string[];
};
};
@@ -187,12 +188,37 @@ describe("GET /api/meta/capabilities — drift guards", () => {
// that was never built.
expect(body.data.share.reportDownload).toEqual(["fhir", "pdf"]);
expect(body.data.share.selectionVersion).toBe(2);
expect(body.data.share.groups).toEqual([...REPORT_GROUP_ORDER]);
expect(body.data.share.groups.map((g) => g.id)).toEqual([
...REPORT_GROUP_ORDER,
]);
expect([...body.data.share.leaves].sort()).toEqual(
[...ALL_LEAF_IDS].sort(),
);
});
it("share.groups carries membership: every leaf in exactly one group, fence flagged once", async () => {
const res = await call();
const body = (await res.json()) as CapabilitiesBody;
const seen = new Set<string>();
for (const group of body.data.share.groups) {
for (const leaf of group.leaves) {
expect(seen.has(leaf), `${leaf} appears in more than one group`).toBe(
false,
);
seen.add(leaf);
}
}
// Every leaf in the flat set is in exactly one group, and no group holds
// a leaf absent from the flat set — the invariant that makes a native
// client's fence trustworthy.
expect([...seen].sort()).toEqual([...body.data.share.leaves].sort());
const sensitiveGroups = body.data.share.groups.filter((g) => g.sensitive);
expect(sensitiveGroups).toHaveLength(1);
expect(sensitiveGroups[0]?.id).toBe(SENSITIVE_GROUP_ID);
});
it("quantityTypes are sourced from the HealthKit ingest mapping", async () => {
const res = await call();
const body = (await res.json()) as CapabilitiesBody;
+7 -5
View File
@@ -46,10 +46,7 @@ import {
FHIR_SEARCH_PARAMS,
} from "@/lib/fhir/rest";
import { SHARE_LINK_MAX_DAYS } from "@/lib/validations/clinician-share-link";
import {
ALL_LEAF_IDS,
REPORT_GROUP_ORDER,
} from "@/lib/report-selection/catalogue";
import { ALL_LEAF_IDS, SHARE_GROUPS } from "@/lib/report-selection/catalogue";
export const dynamic = "force-dynamic";
@@ -135,7 +132,12 @@ export const GET = apiHandler(async () => {
maxDays: SHARE_LINK_MAX_DAYS,
reportDownload: ["fhir", "pdf"],
selectionVersion: 2,
groups: REPORT_GROUP_ORDER,
// Each group carries its own member leaves, and the fenced tier carries
// `sensitive: true` — the membership the panel, the PDF and the guard
// tests already enforce, published so the native client can mirror the
// fence instead of guessing which leaves it may bundle behind one
// control. `leaves` stays the flat total set for back-compat.
groups: SHARE_GROUPS,
leaves: ALL_LEAF_IDS,
},
});
+19 -2
View File
@@ -113,8 +113,25 @@ const capabilitiesResponse = z
.int()
.describe("Version of the leaf-selection grammar this build speaks."),
groups: z
.array(z.string())
.describe("Selection group ids, in presentation order."),
.array(
z.object({
id: z.string().describe("Group id."),
leaves: z
.array(z.string())
.describe(
"Member leaf ids, in the order the panel renders them.",
),
sensitive: z
.boolean()
.optional()
.describe(
"Present and true only on the fenced tier: no single control may switch on more than one of its leaves.",
),
}),
)
.describe(
"Selection groups, in presentation order, each carrying its member leaf ids.",
),
leaves: z
.array(z.string())
.describe(
@@ -25,7 +25,9 @@ import {
REPORT_GROUPS,
REPORT_GROUP_LABEL_KEYS,
REPORT_GROUP_ORDER,
SENSITIVE_GROUP_ID,
SENSITIVE_LEAF_IDS,
SHARE_GROUPS,
STRUCTURED_LEAF_IDS,
leafLabelKey,
} from "../catalogue";
@@ -80,6 +82,49 @@ describe("report selection catalogue", () => {
});
});
/**
* `SHARE_GROUPS` is what `GET /api/meta/capabilities` publishes as
* `share.groups` — the membership a native client needs to mirror the
* sensitive-tier fence itself. It is derived from `REPORT_GROUPS`, so these
* checks are the invariant that makes that fence trustworthy: every leaf in
* the flat `ALL_LEAF_IDS` set lands in exactly one group, no group carries a
* leaf the flat set doesn't have, and exactly one group — the one whose id is
* `SENSITIVE_GROUP_ID` — carries `sensitive: true`.
*
* Mutation check (verified red, then reverted): push a leaf into two groups
* in `SHARE_GROUPS` by hand → the "exactly one group" assertion goes red
* naming the duplicated leaf.
*/
describe("share-group membership (GET /api/meta/capabilities share.groups)", () => {
it("covers exactly the flat leaf set, no leaf in more than one group", () => {
const seen = new Set<string>();
for (const group of SHARE_GROUPS) {
for (const leaf of group.leaves) {
expect(seen.has(leaf), `${leaf} appears in more than one group`).toBe(
false,
);
seen.add(leaf);
}
}
expect([...seen].sort()).toEqual([...ALL_LEAF_IDS].sort());
});
it("flags sensitive: true on exactly the fenced group", () => {
const flagged = SHARE_GROUPS.filter((g) => g.sensitive);
expect(flagged).toHaveLength(1);
expect(flagged[0]?.id).toBe(SENSITIVE_GROUP_ID);
});
it("orders groups and leaves identically to REPORT_GROUPS", () => {
expect(SHARE_GROUPS.map((g) => g.id)).toEqual(
REPORT_GROUPS.map((g) => g.id),
);
for (const [i, group] of SHARE_GROUPS.entries()) {
expect(group.leaves).toEqual(REPORT_GROUPS[i]!.leaves);
}
});
});
describe("template purity", () => {
it("keeps the shipped template clear of the fenced tier", () => {
const overlap = STANDARD_TEMPLATE_LEAVES.filter((leaf) =>
+22
View File
@@ -344,6 +344,28 @@ export const ALL_LEAF_IDS: readonly ReportLeafId[] = REPORT_GROUPS.flatMap(
(group) => group.leaves,
);
/**
* The wire shape for `GET /api/meta/capabilities` `share.groups`: each group
* carries its own member leaves, and the fenced tier carries `sensitive:
* true`, derived from `SENSITIVE_GROUP_ID` rather than a second string
* literal. A native client that cannot see group membership cannot mirror the
* fence (no single control switching on more than one sensitive leaf), so
* this — not `REPORT_GROUP_ORDER` alone — is what the endpoint should ship.
*
* Order matches `REPORT_GROUPS` exactly: group order is `REPORT_GROUP_ORDER`,
* leaf order within a group is structured-then-measurement, so this shape and
* the flat `ALL_LEAF_IDS` list agree leaf-for-leaf.
*/
export const SHARE_GROUPS: ReadonlyArray<{
id: ReportGroupId;
leaves: readonly ReportLeafId[];
sensitive?: true;
}> = REPORT_GROUPS.map((group) => ({
id: group.id,
leaves: group.leaves,
...(group.id === SENSITIVE_GROUP_ID ? { sensitive: true as const } : {}),
}));
const ALL_LEAF_SET = new Set<string>(ALL_LEAF_IDS);
/** Whether a raw string is a leaf id this build knows. */