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 routePUT /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_NAMESare addressable; - only the literal values
trueandfalseare accepted; manage_workspace_secretsis required;- it never reads a secret back.
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 requirescreate_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 requireENABLE_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 reportsactivationEnabled: 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 inapps/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: Contactsuse-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.