docs: publish the last-Guardian refusal where it is actually raised

The floor was described on the one route that cannot reach it and left unsaid
on the two that answer it every time: withdrawing the last Guardian grant and
renouncing it both raise `managed_profile.guardian.required`, and both
published only 'that access had already ended'. Both 409s now name their two
codes, and the annotation on the removal route enumerates all four emitters
rather than three.

A guard closes the class rather than the instance: every route module that
raises the refusal must publish it on its own operation, with the emitters
discovered from the sources.

The freeze guard gains its containment leg — a module becomes delegable by
review, not by discovery, so every file contributing a fenced verb has to be a
member of one of the surface guard's frozen allowlists.
This commit is contained in:
Marc-André Bombeck committed 2026-08-07 05:43:06 +02:00
1 parent 84475ea2f2
commit 4d86dceb99
4 files changed
+145 -8

No files matched your search

+15 -5
View File
@@ -12888,7 +12888,11 @@ paths:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"409":
description: That access had already ended.
description: "Two refusals share this status. `meta.errorCode: sharing.revoke.already_ended` — that access had already
ended, and nothing changed. `meta.errorCode: managed_profile.guardian.required` — the grant is the last
active Guardian grant on a managed profile, and ending it would leave a record with nobody able to open it.
The floor is real and this is one of the four routes that enforce it; the way past it is to invite another
Guardian and wait for them to accept, or to delete the profile."
content:
application/json:
schema:
@@ -12925,7 +12929,11 @@ paths:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"409":
description: That access had already ended.
description: "Two refusals share this status. `meta.errorCode: sharing.renounce.already_ended` — that access had already
ended, and nothing changed. `meta.errorCode: managed_profile.guardian.required` — the caller is the last
active Guardian of a managed profile, so handing this access back would strand the record. Invite somebody
and wait for them to accept, or delete the profile; a Guardian may not simply walk away from a record that
has no self."
content:
application/json:
schema:
@@ -13210,9 +13218,11 @@ paths:
description: "The record would be left with nobody looking after it (`meta.errorCode:
managed_profile.guardian.required`). **Not reachable through this route today**, and published anyway rather
than silently: the caller must already be one active Guardian and the target is never the caller, so an
active target is always a second one — and two is never the one the floor refuses. The same code IS emitted
by the renounce route, by Guardian account deletion and by a data wipe, so a shared client resolver keeps
this arm. It is documented here so that a change making it reachable is a change somebody notices."
active target is always a second one — and two is never the one the floor refuses. The routes that DO emit
this code are `DELETE /api/account/grants/{id}` (the owner withdrawing the last Guardian grant), `POST
/api/account/grants/{id}/renounce` (a Guardian handing their own access back), Guardian account deletion and
a data wipe — a client resolves the code the same way wherever it arrives, which is why this arm stays. It
is documented here so that a change making it reachable through this route is a change somebody notices."
content:
application/json:
schema:
@@ -2,9 +2,67 @@ import { readFile, readdir } from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { openApiPaths } from "@/lib/openapi/routes";
const root = process.cwd();
/** Every `route.ts` under the API tree. */
async function apiRouteFiles(): Promise<string[]> {
const entries = await readdir(path.join(root, "src/app/api"), {
recursive: true,
});
return entries
.filter((entry) => entry.endsWith("route.ts"))
.map((entry) => path.join("src/app/api", entry));
}
/** The published path a route file serves, or null when it is unpublished. */
function publishedPathFor(file: string): string | null {
const apiPath = file
.replace(/^src\/app/, "")
.replace(/\/route\.ts$/, "")
.replace(/\[([^\]]+)\]/g, "{$1}");
return apiPath in openApiPaths ? apiPath : null;
}
describe("managed-profile contract", () => {
it("publishes the last-Guardian refusal on every published route that emits it", async () => {
// The failure this catches is the one the release audit found: the floor
// is enforced on four surfaces and was DESCRIBED on the one route that
// cannot reach it, while two routes that answer it every time said only
// "that access had already ended". Emitters are discovered from the route
// sources, so a fifth one cannot ship undescribed.
const files = await apiRouteFiles();
expect(files.length).toBeGreaterThan(100);
const emitters: string[] = [];
for (const file of files) {
const source = await readFile(path.join(root, file), "utf8");
if (!source.includes("LastManagedGuardianError")) continue;
if (!source.includes("managed_profile.guardian.required")) continue;
emitters.push(file);
}
// Non-zero, and known: the two grant routes. The lifecycle surfaces that
// also raise it (account deletion, the data wipe) answer through their own
// envelopes and are not in this spec.
expect(emitters).toContain("src/app/api/account/grants/[id]/route.ts");
expect(emitters).toContain(
"src/app/api/account/grants/[id]/renounce/route.ts",
);
const silent = emitters.filter((file) => {
const apiPath = publishedPathFor(file);
if (apiPath === null) return false;
return !JSON.stringify(openApiPaths[apiPath]).includes(
"managed_profile.guardian.required",
);
});
expect(
silent,
"published routes that emit the last-Guardian refusal without publishing it",
).toEqual([]);
});
it("requires a cookie-backed fresh MFA proof for creation", async () => {
const source = await readFile(
path.join(root, "src/app/api/managed-profiles/route.ts"),
@@ -45,6 +45,14 @@
* - Comments are stripped before matching. `.../guardians/route.ts` names both
* fence helpers in a docblock explaining why it uses neither, and counting
* prose would classify an actor surface as delegable.
* - It covers the PUBLISHED surface, which is not the whole delegable surface.
* 116 route modules resolve the fence; 90 of them are in the OpenAPI document
* and 26 have never been (mood entries, the record-settings tree, the
* medication intake tree, several insight reads). That gap predates this
* release and is not closed here — "frozen" means the published contract is
* complete and consistent, not that every delegable route is described. What
* this guard does guarantee is that one of those 26 cannot be published
* without its refusal, because publishing it puts it in scope on the next run.
*/
import { readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
@@ -256,6 +264,65 @@ describe("the sharing refusal every delegable path publishes", () => {
expect(overreaching.map(name)).toEqual([]);
});
it("draws only from the reviewed delegable allowlists", () => {
// Containment, not equality, and per FILE rather than per verb — which is
// how `sharing-surface-guard.test.ts` keys its lists, because the
// declaration a human reviewed is "this module may act on another record",
// with the verb-level detail in the entry's prose beside it.
//
// What this closes: the guard above recomputes the delegable set from the
// route sources, so a route that started resolving the fence would be
// discovered and dressed with a refusal automatically — published as
// delegable without anybody deciding it should be. Held to the reviewed
// lists, that becomes two failures instead of a silent contract change.
const guard = readFileSync(
join(ROOT, "src/__tests__/sharing-surface-guard.test.ts"),
"utf8",
);
const allowlist = (name: string): string[] => {
const start = guard.indexOf(`const ${name}`);
expect(
start,
`${name} is missing from the surface guard`,
).toBeGreaterThan(-1);
const block = guard.slice(start, guard.indexOf("\n};", start));
const keys = [...block.matchAll(/^ {2}"(app\/[^"]+)":/gm)].map(
(match) => match[1],
);
// A block that matched nothing would make the union vacuous and the
// containment below unfalsifiable.
expect(keys.length, name).toBeGreaterThan(0);
return keys;
};
const reviewed = new Set([
...allowlist("DELEGABLE_ROUTES"),
...allowlist("DELEGABLE_MANAGE_ROUTES"),
...allowlist("GUARDIAN_ROUTES"),
]);
expect(reviewed.size).toBeGreaterThan(50);
const contributing = new Set(
fenced.map((operation) =>
routeModulePath(operation.path)
.slice(join(ROOT, "src/").length)
.replaceAll("\\", "/"),
),
);
expect(contributing.size).toBeGreaterThan(50);
const unreviewed = [...contributing].filter((file) => !reviewed.has(file));
if (unreviewed.length > 0) {
throw new Error(
`${unreviewed.length} published operation(s) resolve the record fence from a module the surface guard has not reviewed:\n` +
unreviewed.map((file) => ` ❌ src/${file}`).join("\n") +
"\n\nA module becomes delegable by decision, not by discovery: add it " +
"to the matching list in sharing-surface-guard.test.ts with the " +
"reason, or take the fence back out.",
);
}
});
it("carries the refusal into the generated document", () => {
const spec = readFileSync(join(ROOT, "docs/api/openapi.yaml"), "utf8");
expect(spec).toContain(SHARING_ACCESS_DENIED_DESCRIPTION.slice(0, 60));
+5 -3
View File
@@ -596,7 +596,8 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: errorEnvelope } },
},
"409": {
description: "That access had already ended.",
description:
"Two refusals share this status. `meta.errorCode: sharing.revoke.already_ended` — that access had already ended, and nothing changed. `meta.errorCode: managed_profile.guardian.required` — the grant is the last active Guardian grant on a managed profile, and ending it would leave a record with nobody able to open it. The floor is real and this is one of the four routes that enforce it; the way past it is to invite another Guardian and wait for them to accept, or to delete the profile.",
content: { "application/json": { schema: errorEnvelope } },
},
},
@@ -630,7 +631,8 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: errorEnvelope } },
},
"409": {
description: "That access had already ended.",
description:
"Two refusals share this status. `meta.errorCode: sharing.renounce.already_ended` — that access had already ended, and nothing changed. `meta.errorCode: managed_profile.guardian.required` — the caller is the last active Guardian of a managed profile, so handing this access back would strand the record. Invite somebody and wait for them to accept, or delete the profile; a Guardian may not simply walk away from a record that has no self.",
content: { "application/json": { schema: errorEnvelope } },
},
},
@@ -830,7 +832,7 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"409": {
description:
"The record would be left with nobody looking after it (`meta.errorCode: managed_profile.guardian.required`). **Not reachable through this route today**, and published anyway rather than silently: the caller must already be one active Guardian and the target is never the caller, so an active target is always a second one — and two is never the one the floor refuses. The same code IS emitted by the renounce route, by Guardian account deletion and by a data wipe, so a shared client resolver keeps this arm. It is documented here so that a change making it reachable is a change somebody notices.",
"The record would be left with nobody looking after it (`meta.errorCode: managed_profile.guardian.required`). **Not reachable through this route today**, and published anyway rather than silently: the caller must already be one active Guardian and the target is never the caller, so an active target is always a second one — and two is never the one the floor refuses. The routes that DO emit this code are `DELETE /api/account/grants/{id}` (the owner withdrawing the last Guardian grant), `POST /api/account/grants/{id}/renounce` (a Guardian handing their own access back), Guardian account deletion and a data wipe — a client resolves the code the same way wherever it arrives, which is why this arm stays. It is documented here so that a change making it reachable through this route is a change somebody notices.",
content: { "application/json": { schema: errorEnvelope } },
},
},