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

+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. */