Skip to main content
Several Contacts modules used to answer every non-happy path with notFound(). That hid the three situations an operator can actually act on, and it hid a plain bug: pages 404’d personal workspaces even though the sidebar links them and the underlying APIs normalize personal into a real workspace id. Those pages now resolve one of four states through resolveWorkspaceFeatureAccess.

Gate states

The order matters: an unresolved workspace is reported before a permission denial, and a permission denial before the toggle, so a member never learns a module’s configuration from a page they cannot open.

Modules and toggles

Shared workspaces keep whatever each module defaulted to before the gate existed, so no workspace loses a module. Personal workspaces start opted out and opt in from the page itself, which is what replaces the old hard 404.
Approvals has no module toggle of its own: ENABLE_POST_APPROVAL and ENABLE_REPORT_APPROVAL already govern approval behavior, and a third switch would be ambiguous. Approvals also stays unavailable in personal workspaces by product decision — it renders an explanation, not a 404.

Writing a toggle from Contacts

Config toggles go through the Contacts-owned route PUT /api/v1/workspaces/{wsId}/settings/{configId}, which is satellite-session aware. Reading a toggle needs only the module’s view permission; writing needs manage_workspace_settings. Topic announcements is a workspace secret, not a config, and the platform secrets endpoint authenticates through the Supabase cookie — which resolves anonymous on a satellite domain, so every write from Contacts 401s there. PUT /api/v1/workspaces/{wsId}/settings/feature-secrets/{secretName} exists for exactly that case. It is deliberately not a general secrets endpoint:
  • only names in CONTACTS_FEATURE_SECRET_NAMES are addressable;
  • only the literal values true and false are accepted;
  • manage_workspace_secrets is required;
  • it never reads a secret back.
workspace_secrets has no unique index on (ws_id, name), and the platform endpoint inserts without checking, so a workspace can hold duplicate rows for one name. The feature-secret route updates every matching row before falling back to an insert, which also repairs those duplicates. Do not switch it to an upsert with a conflict target until that index exists.

Creating group score indicators

The indicator name is required before creation is enabled. Indicator and metric category dialogs keep their action footer inside the viewport and scroll the form body, including long category lists. Failed creation keeps the draft open and shows an inline error so the user can retry. Repeated taps during a pending save submit once. Creation still requires create_user_groups_scores; members with that permission use the same path as administrators.

Topic announcement list and preview admission

Contacts owns the collection GET and preview POST locally. Both resolve the verified Contacts app-session actor and workspace membership before reading topic content, then require ENABLE_TOPIC_ANNOUNCEMENTS to equal true, a non-personal workspace, and manage_users. The module remains off by default; listing or previewing does not enable it or send email. Preview uses only contacts in the requested workspace that are not archived. Web retains its Supabase-session admission. Both apps use the same users-core query, payload validation, recipient verification status, attachment filename normalization, and preview renderer. Contacts forwards collection mutations to Web after its existing session and API edge guards; other topic mutation routes and the delivery worker retain their current ownership. This read/preview repair does not establish satellite mutation or provider-delivery readiness. Regression coverage exercises the local Contacts routes with the actual workspace access helper, denied sessions and membership/capabilities, shared recipient serialization, and exact-method forwarding. Web route and renderer regressions preserve its existing behavior. These adapter-level checks require exact-app CI build and authenticated runtime verification before delivery is confirmed. Before dispatch preparation, the topic sender rechecks the current workspace ENABLE_TOPIC_ANNOUNCEMENTS secret. Missing or non-true values deny sending. Every linked recipient must still exist, belong to that workspace, and be active; a missing, archived, or moved recipient blocks the entire intended batch. It does not silently send to fewer recipients. Database read errors remain failures. This prerequisite does not change announcement history, statuses, or queue leases. Provider acceptance, durable receipt publication, and safe recovery from ambiguous processing remain separate delivery requirements.

Inert topic delivery ledger

The private delivery-attempt foundation reserves a parent revision and an immutable attempt snapshot. It has no service write API, no sender integration, and no historical backfill. Readiness explicitly reports activationEnabled: false. Existing announcement, recipient, attachment, queue, and feature-toggle behavior stays unchanged; the topic feature remains off by default. This foundation alone does not repair uncertain delivery or prevent replay by the existing queue. The topic-delivery-foundation-contract.yaml workflow applies the complete schema, runs strict pgTAP and real two-connection ownership/snapshot contention, and generates actual schema types for the exact commit. Local source checks are not SQL or provider-delivery proof. Browser roles cannot access the new private ledger, and its service role receives read access only. Activation requires a separate reviewed atomic caller conversion, current feature and actor-permission admission, and an accepted/rejected/unknown provider-outcome contract. No uncertain attempt may be treated as a safe automatic retry or discarded history.

Attendance edits during saving

Contacts and shared group attendance rosters keep edits made while a save is in flight. Success acknowledges only the submitted draft entries in their original workspace, group, date, and session. Later edits remain visible and available for an explicit subsequent save, including an edit back to the original text. Drafts stay pending until saved or explicitly reset; changing attendance scope starts a new draft. The submitted transport still includes the complete status and note. A rejected save reports the error, keeps the draft visible, and leaves retry to the user; it does not clear the draft or refresh it from stale server data. The deferred-save component regressions in apps/contacts/src/__tests__/attendance-note-save.test.tsx cover later edits, reverting text during a save, rejected saves and explicit retries, status-only changes, explicit empty notes and reloads, and older saves completing after workspace, group, date, or session navigation. This rule does not change explicit attendance clearing or post-check reset semantics, and does not recover historical notes.

Class-post dialog updates

Contacts class-post dialogs update only fields changed since that dialog opened. Saving a title or content edit leaves untouched class-post notes on the server, including notes saved by another editor. Explicit empty or null notes still clear the field. Creation sends the complete title, content and notes payload. Create fields are locked while submission is pending so later edits cannot create a duplicate post. Failed saves retain the draft. A completed save closes only its submitted draft; newer edits or a different workspace, group or post stay open. This protects local edits and untouched fields, but concurrent edits to the same field still use the server’s last write. It does not apply to attendance or recipient check notes and does not recover previously lost content. Regression coverage: Contacts use-posts.test.tsx exercises the real dialog hooks and source API transport for stale snapshots, explicit clears, failed saves, newer edits, target changes and full creation payloads.