Skip to content
Merged
2 changes: 1 addition & 1 deletion docs/design-system/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ ancestor form in the same commit, both here and in the cascade port (SPEC §4.1)

---

## C5 · Three identity families cover fifteen modes
## C5 · Three identity families cover seventeen modes

**Chose.** Three families — `--kind-source`, `--kind-answer`, `--kind-workspace` — with the
mode mapping in SPEC §3. No fourth family. The four hue tones and the two specifier tones
Expand Down
2 changes: 1 addition & 1 deletion docs/design-system/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ answer's treatment, and a partial answer must never render as a whole one._ Thes

Full reasoning in DECISIONS §C5. The resolution:

**Three semantically-named identity families cover the fifteen modes**, because the system has
**Three semantically-named identity families cover the seventeen modes**, because the system has
**three separate colour channels** that must never borrow from each other:

| Channel | Job | Tokens |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"version": 2,
"id": "a2d978f5-aaba-47e6-afee-669d392cb716",
"createdOn": "2026-09-05",
"action": "add",
"payload": {
"pri": "P3",
"type": "issue",
"summary": "on_call_entries.linked_document_ids is uuid[], so Postgres cannot enforce a foreign key to documents",
"detail": "supabase/migrations/20260904120000_on_call_entries.sql:25 declares linked_document_ids uuid[] not null default '{}'. Postgres cannot place a foreign key on an array element, so nothing at the database layer stops an entry referencing a deleted or non-existent document. Behaviour is conservative: the viewer resolves each id and simply omits links it cannot resolve, so a stale id degrades to a missing link rather than a wrong or broken one. No symptom has been observed. Recommendation: LEAVE AS-IS. Enforcing it properly means restructuring the link into a join table (on_call_entry_documents) with a real foreign key and a migration to backfill, which is disproportionate to a defect that fails safely. Revisit only if entries ever start displaying stale links in practice, or if the join table is wanted for another reason (per-link ordering, labels, or reverse lookup from a document to the entries citing it).",
"source": "Session 2026-09-05, owner-reported loose end after the On Call mode build",
"issueUlid": "01M1RE7RB7NADB8P8Z5H1ZYG39"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"version": 2,
"id": "bc06ad0c-44a5-4a0b-8caa-bf426155c6e1",
"createdOn": "2026-09-05",
"action": "add",
"payload": {
"pri": "P2",
"type": "rec",
"summary": "Home-page LCP sits on the Lighthouse trip-wire because the search-app loading.tsx Suspense boundary delays the hero heading",
"detail": "Measured 2026-09-05 on PR #2633: the hero heading paints ~750-880ms in because src/app/(search-app)/loading.tsx is a Suspense boundary, so React streams the real hero into a hidden div and reveals it on a later requestAnimationFrame. That is architectural, not a regression — an A/B of 9ca3b4e95 vs e09b41b6 on an identical browser build gave 881.3ms vs 881.5ms, refuting an earlier attribution to PR #2600. It sits close enough to the relative Lighthouse budget (which trips only on BOTH +20% and +100ms) that unrelated PRs keep flirting with it. Recommendation: rebaseline the Lighthouse budget for the home route rather than change first-paint behaviour, because removing the boundary changes how the first screen appears to users for a problem that is otherwise cosmetic to CI. Next action: decide rebaseline vs behaviour change with the owner, then adjust the pinned baseline (browser-pinned, path-scoped) in one PR.",
"source": "Session 2026-09-05, PR #2633 On Call public visibility; owner asked for the slowdown to be root-caused",
"issueUlid": "01M1RE7FFP8KXPKKD2AKYZ29HZ"
}
}
22 changes: 20 additions & 2 deletions docs/superpowers/specs/2026-09-04-on-call-mode-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ stores no patient information, and it never authors clinical guidance in the app

| Decision | Chosen | Rejected, and why |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Where content lives | Private rows in Supabase, owner-scoped, edited in the app | Committing real contacts/manuals into the repository — exposes internal service information to anyone with repo access and makes every correction a code change |
| Audience for v1 | The owner alone | Cohort sharing. The app has exactly two visibility states, owner-only and fully public, and `psychiatry.tools` has no login wall, so the only available "share" would publish a hospital's internal contacts to the open internet. Deferred to its own reviewed project (§12) |
| Where content lives | Rows in Supabase, owner-written and ~~owner-scoped~~ publicly readable (amended below), edited in the app | Committing real contacts/manuals into the repository — exposes internal service information to anyone with repo access and makes every correction a code change |
| Audience for v1 | ~~The owner alone~~ → **every visitor** (amended 2026-09-04, see below) | Cohort sharing. The app has exactly two visibility states, owner-only and fully public, and `psychiatry.tools` has no login wall, so the only available "share" would publish a hospital's internal contacts to the open internet. Deferred to its own reviewed project (§12) |
| Referrals list | On Call keeps its own list | Reusing the Services registry — the owner asked for a separate list shaped for on-call use |
| Orientation manuals | The uploaded PDF is the source of truth; the owner may pin a short summary above it, labelled as the owner's note | Retyping manual content as app pages — creates a second copy that silently drifts from the real manual |
| Rosters | Not built | A hand-maintained "who is on tonight" decays within a fortnight, and its failure mode is a junior calling the wrong person in an emergency. Role-based contacts give the same benefit without decay |
Expand All @@ -31,6 +31,24 @@ stores no patient information, and it never authors clinical guidance in the app
| Freshness | Every entry carries a last-checked date and shows an unmissable stale state after **12 months** | Silent rot |
| Print | A one-page essentials card, personal details excluded | No print — juniors carry paper, and it is the safe way to share while group access does not exist |

> **Amendment — 2026-09-04, owner decision.** The audience row above no longer describes the
> shipped behaviour. The owner was shown what "public" means in this app — no login wall, so
> public is readable by anyone who reaches `psychiatry.tools`, and there is no signed-in-colleagues
> tier to choose instead — and chose to publish the content on that basis.
>
> What changed: reads are no longer owner-scoped. `fetchSharedOnCallEntries` returns every entry
> to any caller, signed in or not; `fetchVisibleOnCallEntries` adds the viewer's own entries on
> top. Writes are unchanged — creating or editing still requires an account and still stamps
> `owner_id`.
>
> What did NOT change: an entry flagged `is_personal` is never returned by the shared read. The
> editor labels that checkbox "Personal number — excluded from the printable card and any export",
> and a world-readable fetch is an export, so those entries stay with the account that wrote them.
> `tests/on-call-repository.test.ts` pins this, and the assertion was mutation-tested.
>
> The cohort-sharing project in §12 is still the right answer for "my colleagues but not the
> internet"; this amendment does not deliver it.

## 3. Sections

Six sections, one underlying entry store. Adding a seventh later is cheap.
Expand Down
14 changes: 7 additions & 7 deletions src/app/api/on-call/entries/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
onCallEntrySchema,
type OnCallSection,
} from "@/lib/on-call/entry-model";
import { fetchOwnerOnCallEntries, onCallEntryToRow, rowToOnCallEntry } from "@/lib/on-call/repository";
import { fetchVisibleOnCallEntries, onCallEntryToRow, rowToOnCallEntry } from "@/lib/on-call/repository";
import { publicAccessContext } from "@/lib/public-api-access";
import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth";
Expand Down Expand Up @@ -96,12 +96,12 @@ export async function GET(request: Request) {
return rateLimitJsonResponse("On Call requests are rate limited. Try again shortly.", rateLimit);
}

if (!access.ownerId) {
return NextResponse.json({ entries: [], signedOut: true });
}

const entries = await fetchOwnerOnCallEntries(supabase, access.ownerId, { section });
return NextResponse.json({ entries, signedOut: false });
// On Call is a shared reference surface: an anonymous caller gets the shared entries rather
// than an empty list. Deliberate owner decision (2026-09-04) — see the visibility note on
// fetchSharedOnCallEntries. `signedOut` still reports whether the caller has an account,
// because the client uses it to decide whether editing is offered, not whether to render.
const entries = await fetchVisibleOnCallEntries(supabase, access.ownerId, { section });
return NextResponse.json({ entries, signedOut: !access.ownerId });
} catch (error) {
if (error instanceof AuthenticationError) {
return unauthorizedResponse();
Expand Down
14 changes: 5 additions & 9 deletions src/components/on-call/on-call-search-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ function OnCallSearchResultRow({ entry, now }: { entry: OnCallEntry; now: Date }
* sheet, never a native `<select>`.
*/
export function OnCallSearchPage({ initialQuery = "", now: nowProp }: { initialQuery?: string; now?: Date }) {
const { entries, loading, isOffline, signedOut } = useOnCallEntries();
const { entries, loading, isOffline } = useOnCallEntries();
// Read the clock once per mount, not once per render: freshness must not
// change between two renders of the same result list, and a bare
// `new Date()` in the render body is a new value on every pass.
Expand All @@ -119,8 +119,8 @@ export function OnCallSearchPage({ initialQuery = "", now: nowProp }: { initialQ
const [filterOpen, setFilterOpen] = useState(false);
const filterPanelId = useId();

const status = onCallSearchStatus({ loading, isOffline, signedOut, entryCount: entries.length });
const faulted = status === "error" || status === "unauthorized";
const status = onCallSearchStatus({ loading, isOffline, entryCount: entries.length });
const faulted = status === "error";

const ranked = useMemo(() => rankOnCallEntries(entries, initialQuery), [entries, initialQuery]);
const sectionFiltered = useMemo(
Expand Down Expand Up @@ -181,12 +181,8 @@ export function OnCallSearchPage({ initialQuery = "", now: nowProp }: { initialQ
matchCount={displayed.length}
status={status}
headingLevel={1}
faultTitle={status === "unauthorized" ? "Sign in to search" : "On Call could not be loaded"}
faultBody={
status === "unauthorized"
? "Your session has expired. Sign in again to search your on-call information."
: "This device has no offline copy of your on-call entries to search. Reconnect and try again."
}
faultTitle="On Call could not be loaded"
faultBody="This device has no offline copy of the on-call entries to search. Reconnect and try again."
sortValue={sortValue}
onSortChange={setSortValue}
mobileControlsPlacement="inline"
Expand Down
17 changes: 13 additions & 4 deletions src/lib/on-call/entry-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,10 +177,19 @@ export function useOnCallEntries(): OnCallEntriesState {
setIsOffline(false);
setSignedOut(parsedResponse.data.signedOut);
setFetched(entries);
// Never write a signed-out (always-empty) response over a good cache:
// a session expiring mid-shift must not erase numbers that were
// readable a moment earlier.
if (!parsedResponse.data.signedOut) {
// A signed-out response is cacheable now that it carries the shared entries rather
// than an empty list (owner decision, 2026-09-04), so the old "skip when signedOut"
// guard would throw away good data.
//
// Its underlying reason still stands, restated against what actually matters: an
// EMPTY response must not erase a non-empty cache. That is what protected a shift
// when a session expired, and it now also covers the server returning nothing for
// any other reason. Deliberate clearing has its own path (`clearOnCallEntryCache`,
// called on sign-out and account switch), so nothing depends on an empty fetch to
// do it. A session expiring still drops the owner's own `is_personal` entries on the
// next non-empty fetch, which is correct — those are the one thing a signed-out
// caller is not shown.
if (entries.length > 0 || readCachedOnCallEntries() === null) {
cacheOnCallEntries(entries);
}
} catch {
Expand Down
48 changes: 48 additions & 0 deletions src/lib/on-call/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,54 @@ export function onCallEntryToRow(entry: OnCallEntry, ownerId: string) {
};
}

/**
* On Call is a shared reference surface: every entry is readable by any visitor, signed in or
* not. That is a deliberate visibility decision (owner request, 2026-09-04) and a reversal of
* this mode's original owner-only design — see docs/superpowers/specs/2026-09-04-on-call-mode-design.md.
*
* The app has no login wall, so "public" here means readable by anyone who reaches the site,
* not "readable by signed-in colleagues". There is no cohort tier to fall back on.
*
* ONE thing is never published: an entry flagged `is_personal`. The editor labels that checkbox
* "Personal number — excluded from the printable card and any export", and a world-readable
* fetch is an export. A personal number therefore stays with the account that wrote it, and is
* returned only to that owner by `fetchOwnerOnCallEntries`.
*
* Writes are unchanged: creating or editing still requires an account and still stamps owner_id.
*/
export async function fetchSharedOnCallEntries(supabase: AdminClient, options: { section?: OnCallSection } = {}) {
let query = supabase.from("on_call_entries").select(ROW_COLUMNS).eq("is_personal", false);
if (options.section) query = query.eq("section", options.section);
const { data, error } = await query.order("sort_order").limit(ON_CALL_MAX_ENTRIES);
if (error) throw new Error(error.message);
return (data ?? []).map((row) => rowToOnCallEntry(row as Record<string, unknown>));
}

/**
* What a given viewer sees: every shared entry, plus their own entries including the personal
* ones the shared read withholds.
*
* Two queries rather than one `or(...)` filter, because a PostgREST `or=` string interpolates
* the owner id into filter syntax where a comma or parenthesis stops being data — the trap
* `withOwnerReadScope` guards with a UUID pattern. Merging two `.eq()` reads has no such edge,
* and both are capped at ON_CALL_MAX_ENTRIES.
*/
export async function fetchVisibleOnCallEntries(
supabase: AdminClient,
viewerOwnerId: string | undefined,
options: { section?: OnCallSection } = {},
) {
const shared = await fetchSharedOnCallEntries(supabase, options);
if (!viewerOwnerId) return shared;

const own = await fetchOwnerOnCallEntries(supabase, viewerOwnerId, options);
const byId = new Map(shared.map((entry) => [entry.id, entry]));
// The owner's own copy wins on collision: it is the same row, and this keeps one identity per
// entry rather than two objects a renderer would key twice.
for (const entry of own) byId.set(entry.id, entry);
return [...byId.values()].sort((a, b) => a.sortOrder - b.sortOrder);
}

export async function fetchOwnerOnCallEntries(
supabase: AdminClient,
ownerId: string,
Expand Down
11 changes: 7 additions & 4 deletions src/lib/on-call/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,17 @@ import { onCallDetailsSchemaFor, type OnCallEntry } from "@/lib/on-call/entry-mo
* band already accepts any status drawn from its own wider union, so a lib
* module has no need to import a component's type to stay compatible with it.
*/
export type OnCallSearchStatus = "loading" | "ready" | "unauthorized" | "error";
export type OnCallSearchStatus = "loading" | "ready" | "error";

/**
* Derives the band status from `useOnCallEntries()`'s own state.
*
* Being signed out is NOT a fault here. On Call entries are readable by any visitor
* (owner decision, 2026-09-04), so a signed-out caller searches the shared set like anyone
* else; only personal entries are withheld, and their absence is not an error to report.
* `unauthorized` is therefore not in this mode's status union, though it stays in the band's
* own wider one for the modes that do gate on a session.
*
* The one case that matters: offline with nothing cached to search over.
* That is not "zero matches" — the search never had anything to run against
* — so it must render as `error`, which is the status the band never lets
Expand All @@ -27,16 +33,13 @@ export type OnCallSearchStatus = "loading" | "ready" | "unauthorized" | "error";
export function onCallSearchStatus({
loading,
isOffline,
signedOut,
entryCount,
}: {
loading: boolean;
isOffline: boolean;
signedOut: boolean;
entryCount: number;
}): OnCallSearchStatus {
if (loading) return "loading";
if (signedOut) return "unauthorized";
if (isOffline && entryCount === 0) return "error";
return "ready";
}
Expand Down
Loading
Loading