Slice 7: §14 chrome + settings and admin neighborhoods

§14.1 richer landing, §14.2 /philosophy route (disk-backed), §14.3
persistent About link. /settings/notifications surfaces Slice 6's
preferences/quiet-hours/mute/watches endpoints. /admin home base
consolidates role management, the §6.2 write-mute, the audit-log
viewer, the permission-events log, and the §13.2 graduation queue.

Backend: backend/app/philosophy.py, backend/app/api_admin.py (seven
admin endpoints + user-search), GET /api/users/me/notification-mutes.
Frontend: Landing.jsx (deck), Philosophy.jsx, NotificationSettings.jsx,
Admin.jsx, App.jsx routing for the chrome surfaces.

Tests: backend/tests/test_chrome_vertical.py — 13 cases. Full suite
75/75 green.

Spec corrections: §14.2 (PHILOSOPHY.md source is a deployment-time
decision), §17 (admin block extended to name the seven new endpoints
+ user-search and notification-mutes read). §19.1 rewritten for
Slice 8 hardening; §19.2 grew four candidates (owner succession,
mute-from-actor, the "Following since <date>" disclosure, audit-log
row prose).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-24 23:40:49 -07:00
parent f67d0aa0db
commit 060fa408a2
14 changed files with 2722 additions and 158 deletions
+169 -84
View File
@@ -1675,10 +1675,14 @@ novel.
### 14.2 The `/philosophy` route
Authenticated and anonymous visitors alike can reach `/philosophy`,
which renders the full body of `PHILOSOPHY.md` from the meta repo. The
content is sourced from the meta repo's main branch, cached and
refreshed on the same cadence as RFC bodies (§4). The page is plain
markdown rendering with no editing affordance.
which renders the full body of `PHILOSOPHY.md`. The content is cached
in the app process and refreshed on demand; the source of the file is
a deployment-time decision — Slice 7's build sources it from the app
repo (the file lives alongside `SPEC.md`, since the philosophy is the
framework's design document rather than an RFC entry), and a
`PHILOSOPHY_PATH` env var can point at a meta-repo working-tree clone
or any other sync target if a deployment prefers that shape. The page
is plain markdown rendering with no editing affordance.
### 14.3 Persistent "About" link
@@ -2318,9 +2322,35 @@ The follow-up session will refine this. A minimal starting set:
- `POST /api/rfcs/<slug>/prs/<pr_number>/withdraw` — withdraw per §10.8.
- `POST /api/rfcs/<slug>/prs/<pr_number>/resolution-branch` — cut a
fresh resolution branch and replay per §10.9.
- `POST /api/admin/users/<id>/role` — set role (owner/admin only).
- `POST /api/admin/users/<id>/mute` — mute/unmute (the §6.2 app-wide
write-mute, not the §15.8 notification mutes).
- `GET /api/admin/users` — list users with role and write-mute state,
for the §6 / Slice 7 admin surface.
- `POST /api/admin/users/<id>/role` — set role. Only owners may grant
or revoke `owner`; admins may flip contributor ↔ admin freely. An
owner-self-demotion is refused on this endpoint; owner succession
earns its own ceremony (§19.2). Writes a `permission_events` row.
- `POST /api/admin/users/<id>/mute` — set the §6.2 app-wide
write-mute (not the §15.8 notification mutes). Refused on owners
and admins — for them, the role-change channel is the right
refusal. Writes a `permission_events` row.
- `GET /api/admin/audit` — paged read of the `actions` log with
filters `action_kind`, `actor_user_id`, `rfc_slug`, plus `before_id`
for the page boundary. Returns the joined actor login/display so
the surface can render row prose without a second round-trip.
- `GET /api/admin/permission-events` — paged read of
`permission_events` (role changes, write-mute toggles), joined
against `users` for actor and subject. Same `before_id` paging.
- `GET /api/admin/graduation-queue` — the §13.2-ready set: returns
super-drafts partitioned into `ready` (owners set, zero open
body-edit PRs) and `blocked` (one or both preconditions missing),
with the precondition shape carried in each row.
- `GET /api/users/me/notification-mutes` — list the §15.8 per-user
mutes the signed-in user has set, joined against `users` for the
rendered handle and display name. The companion read endpoint to
the add/delete pair.
- `GET /api/users/search` — typeahead over `gitea_login` and
`display_name`, ten-row cap, prefix-first ranking. Powers the
§15.8 mute-add typeahead in `/settings/notifications`. Excludes
the caller. Open to any authenticated viewer.
- `POST /api/stars/<slug>` — star/unstar.
- `POST /api/webhooks/gitea` — webhook receiver.
- `GET /api/notifications` — list inbox rows for the signed-in user,
@@ -2405,93 +2435,109 @@ surface. With Topic 13 folded in, the structural surface is
complete. What follows is no longer "topics that block specifying
v1" but "topics to address during or shortly after the v1 build."
### 19.1 Next slice: the §14 chrome and the settings neighborhood
### 19.1 Next slice: hardening
Slice 6 of the build has landed. The §15 notifications surface runs
end-to-end against the local Gitea — every `actions` row whose
`action_kind` maps to a §15.1 event fans out through
`notify.fan_out_from_action`, called inline from `bot._log` and
from the graduation orchestrator's `_audit`. Chat-message inserts
take a parallel path through `notify.fan_out_chat_message` from
inside `chat.append_user_message`, since chat doesn't flow through
the bot wrapper. The §15.6 auto-watch upsert sits in the same
chokepoint — every substantive gesture either creates a `watching`
row or bumps `last_participation_at` for the 90-day decay timer.
Slice 7 of the build has landed. The §14 chrome, the
`/settings/notifications` neighborhood, and the `/admin` home base
all run end-to-end against the local Gitea, and the next slice has
the v1 surface fully wrapped — what remains is the hardening pass
that lets a single-operator deployment actually run.
The §15.4 email loop runs through an SMTP adapter with a stdout
fallback for dev — the in-memory `_SENT` buffer is what the
integration tests read from. The per-category dispatch holds during
§15.8 quiet hours; on window-end, `email.flush_pending` bundles
above the §15.4 threshold into a single "Activity while you were
away" mail. The signed-URL unsubscribe path flips a single category
column to zero; the bounce webhook flips the new `email_opt_out_all`
column (migration `008_email_opt_out.sql`).
The §14.1 landing page now carries the title, the subtitle, the
short-form pitch from `PHILOSOPHY.md`, the sign-in affordance, the
secondary "Read the full philosophy" link, and a three-item deck
underneath the pitch that names what the framework is — one word per
RFC, argued in public with the model, graduation as the load-bearing
moment. The §14.2 `/philosophy` route reads `PHILOSOPHY.md` from
disk (via `backend/app/philosophy.py`, configurable through the
`PHILOSOPHY_PATH` env var) and renders it inline with the existing
`marked` library — the same renderer the proposal preview already
uses. §14.3's persistent About link sits in the header next to the
inbox badge and the new Settings / Admin (admin-only) entries; the
header's visual budget stays tight, and each entry reads as a quiet
text link rather than a button.
The §15.5 digest is a `DigestScheduler` wrapping `cache.Reconciler`'s
shape, with a `run_tick` seam the tests drive synchronously. Each
tick releases held emails, runs the §15.6 90-day decay sweep, and
assembles per-cadence digests where the window has rolled over.
The §15.5 exclusion rules (already-emailed, already-read,
personal-direct-excluded) keep two consecutive ticks idempotent.
The notification-settings surface (`/settings/notifications`,
`frontend/src/components/NotificationSettings.jsx`) lands the five
sub-sections the §15 endpoints already supported: the §15.4 per-
category email toggles (with the `email_watched_churn` toggle
permanently disabled and the §15.4 refusal tooltip inline), the
§15.5 digest cadence dropdown, the §15.8 quiet-hours editor (three
inputs against `Intl.supportedValuesOf('timeZone')`, with all-three-
or-clear validation enforced server-side), the §15.6 watches
overview (per-row state selector that flips `set_by` to `explicit`
on override), and the §15.8 per-user mute list with an unmute
affordance and a typeahead add. Owners and admins see the §15.8
mute-list with the "cannot mute" prose inline. The §15.4 email
footer's `Manage all preferences` link — wired in Slice 6 — now
resolves to a real surface.
§15.2 / §15.3 / §15.7 / §15.8 surface as fourteen endpoints in
`backend/app/api_notifications.py`, plus the chat-seen advance on
`api_branches` and the existing PR seen-cursor on `api_prs` — both
extended to call `notify.reconcile_seen_advance` so the §15.7
visit-advances-cursor loop closes back into the inbox-row read
state. The SSE stream holds a per-user subscriber queue keyed by
user_id; multiple browser tabs see the same events.
The admin home base (`/admin`, `frontend/src/components/Admin.jsx`)
runs as a tabbed left-rail with four panels: Users (role management
+ §6.2 write-mute, with the role-grant constraints enforced
server-side per §6.1 — only owners may grant owner; owners cannot
self-demote on the role endpoint), Graduation queue (super-drafts
partitioned by §13.2 readiness — owners set and zero blocking body-
edit PRs), Audit log (paged read of `actions` with filter chips for
`action_kind`, `actor_user_id`, and `rfc_slug`), and Permission
events (paged read of `permission_events` showing the role and
mute history). Every `/api/admin/*` endpoint guards independently
through `require_admin`, and the User-search endpoint (open to all
authenticated viewers) powers both the admin user-roster and the
mute typeahead.
On the frontend, `App.jsx` grew a header badge (cap "99+",
clicking opens the inbox overlay), an SSE-driven counter that
surfaces personal-direct toasts (own-name signals) and live-view
toasts (events landing on the slug the user is viewing). The
inbox is `Inbox.jsx` — three filter chips (Unread only, RFC,
Category), a Bundle toggle, and a "Mark all read (under filter)"
button. `ToastHost.jsx` caps four visible at once with auto-dismiss.
`backend/app/api_admin.py` carries the seven new admin endpoints
plus the user-search. `backend/app/philosophy.py` carries the
disk-backed `/api/philosophy` source. `backend/app/api_notifications.py`
grew one read endpoint (`GET /api/users/me/notification-mutes`) for
the settings page's mute list. The §17 admin block was extended in
this corrected spec to name the seven endpoints; §14.2 was
corrected to acknowledge the deployment-time decision about where
`PHILOSOPHY.md` lives.
The §15.9 attribution rule fell out cleanly: every `notifications`
row carries `actor_user_id` resolved from the `actions.actor_user_id`
in the originating audit row (the underlying user, never the bot).
System-generated events (digest emission, 90-day decay) leave
`actor_user_id` NULL and render as "the app." AI participation
events landed as null-system per §19.2's candidate naming — when a
chat message authored by an AI provider goes through, no actor row
is written, since the LLM call doesn't have a user_id; the topic
folder for "AI participation as a notification source" in §19.2
remains open for explicit settling.
Slice 7 ships covered by `backend/tests/test_chrome_vertical.py`
thirteen integration tests covering the philosophy route for both
anonymous and authenticated callers, the §15.4 / §15.5 / §15.8
preferences round-trip (including the permanent `email_watched_churn`
refusal), the quiet-hours all-or-nothing validation, the §15.8 mute
add/list/unmute round-trip, the user-search typeahead, the admin
role and write-mute round-trips with their `permission_events`
audit, the §6.1 refusal of owner-grant by non-owners, the audit-log
filter chips, the graduation-queue partition under both
preconditions, and the permission-events listing. The full Slices
17 test suite is 75/75 green.
Slice 6 ships covered by `backend/tests/test_notifications_vertical.py`
— seventeen integration tests covering the producer-side fan-out
on the propose/merge/decline chain, §15.6 auto-watch, the §15.2
inbox listing with filter chips, the §15.7 chat-seen reconciler,
the §15.8 per-user mute and the per-RFC mute, the §15.4 email-
bounce webhook flipping the global opt-out, the `/email/unsubscribe`
signed-URL path, the §15.8 quiet-hours email hold, the §15.5
digest's emit-then-skip behavior across two consecutive ticks,
preferences and quiet-hours round-trips, the explicit-watch
override that prevents auto-downgrade, and the SSE subscriber/
broadcast substrate. The full Slices 16 test suite is 62/62 green.
**Slice 8 is the hardening pass — the last slice of the v1 build.**
Three pieces hang together:
**Slice 7 is the §14 chrome plus the natural notification-settings
neighbor.** With every structural beat live, what remains for v1
is the chrome the framework wraps itself in. §14 commits the
landing page (the unauthenticated visitor's first read), the
`/philosophy` route (PHILOSOPHY.md surfaced inline), and the
persistent About link in the header. Slice 6 left the §15
preferences / quiet-hours / mute / watches endpoints in place
but with no chrome — the natural follow-on is `/settings/notifications`
exposing the per-category toggles, the digest cadence dropdown,
the quiet-hours editor, the watches overview, and the per-user
mute list. The §19.2 "admin surfaces" candidate is the second
natural neighbor — role management, the §6.2 app-wide write-mute,
the audit-log viewer, the graduation-readiness queue, all
consolidated where the chrome can hold them. Slice 7 picks the
framing and ships the three pieces together since they share an
information architecture.
The §12 30/90 branch-hygiene timers — the formalized policy that
closes the loop on §11.5's branch lifecycle (open → merged → 30d
read-only → 90d deleted-by-bot, with the per-user-message-cursor
preservation contract). The wiring is a scheduled task next to the
§15.5 digest scheduler; the §10.7 90-day deletion timer Slice 3
left deferred lives here too.
An end-to-end smoke pass over the working surfaces — propose →
super-draft → branch → PR → merge → graduate → active-RFC PR →
notification fans out → inbox → email — to catch the integration
seams a per-slice test wouldn't. Plus the §19.2 candidates the
hardening pass is the natural place to fold in: cache bootstrap
from a meta repo (the audit-log-first attribution shape Slice 1
chose, exercised against a meta repo with history the bot did not
author), branch-name path routing (converting every
`branches/<branch>` to `{branch:path}` with route-ordering
discipline), and the small Slice-2-onward follow-ons that are
deferred until the hardening pass demands them.
The dev/prod deployment shape — the `deploy/` directory already
has the nginx vhost, the systemd unit, and a runbook stub; Slice 8
proves the bring-up against a fresh host, settles the secret-
material handling (the existing `.env.example` plus the §15.4
SMTP wiring), and lands the README updates that let a new operator
get from `git clone` to a signed-in browser.
The next build session should read `SPEC.md`, `README.md`,
`docs/DEV.md`, and this §19.1 entry and pick up Slice 7 cleanly
`docs/DEV.md`, and this §19.1 entry and pick up Slice 8 cleanly
without re-briefing. The working agreement in §19.3 continues to
apply: implement the slice, correct the spec only where running
code reveals it was wrong at a structural level, accumulate new
@@ -2785,6 +2831,45 @@ binding.
or signature verification (Sendgrid's signed events, AWS SES's
SNS topic signature, etc.). Trivial to add per provider; the
routing-and-flip-the-column logic doesn't change.
- **Owner succession ceremony.** Slice 7's `POST /api/admin/users/<id>/role`
refuses self-demotion ("Use the explicit succession path to change
your own role") because owner-zero is the only owner bootstrap path
per §6.1 and a careless self-downgrade could orphan the role. The
explicit succession path — how an owner steps down, whether owner-
zero needs a co-owner present, how the `OWNER_GITEA_LOGIN` env var
relates to the seated-owner set after the bootstrap moment — is the
natural follow-on once a real owner-transition scenario shows up.
Touches §6.1 (the owner-role bootstrap rule), §17 (the admin role
endpoint), and possibly §3.1 (state-transition shape if owner
changes are themselves a tracked transition).
- **Mute-from-actor on inbox rows and chat messages.** Slice 7's
notification-settings page exposes the per-user mute list with an
unmute affordance, and an intentionally clumsy typeahead for the
add path. The natural add path — clicking the actor on an inbox
row or a chat message — is the §19.2 candidate of its own this
slice was always going to surface. Touches §15.8 (the mute add
ergonomics), §15.2 (the inbox row's actor slot), and §8.12 (the
chat message's author chip). Small scope, defer-able until the
typeahead-only path proves annoying.
- **The "Following since &lt;date&gt;" disclosure on the RFC view header.**
§15.6 commits this disclosure on the RFC view's header after an
auto-decay. Slice 7 lands the watches overview at
`/settings/notifications` — the centralized read — and defers the
per-RFC chrome to a later session, since the disclosure earns its
surface only once auto-decay has actually fired against a watch in
production use. Touches §15.6 (the decay timer's UX) and §8.1 (the
RFC view's header strip).
- **Audit-log row prose translation.** Slice 7's `/admin → Audit log`
surface renders `action_kind` as the raw enum (`merge_branch_pr`,
`propose_rfc`, `graduate_step_complete`, etc.) inside a `<code>`
span. Admins know the kinds; a contributor-facing surface — if one
ever earns its place — would need a per-kind English render
("merged the body-edit PR", "proposed RFC", "completed the
repo-create step of graduation"). The translation table lives near
`notify.SUMMARY` per §15.9 — a future session can lift the same
surface to the admin view if usage shows reviewers want it.
Defer-able until evidence of demand. Touches §6.5 (the audit-log
surface) and §17 (the audit endpoint's prose shape).
- **Per-user mute exemption checks for arbiters.** §15.8 commits
that arbiters cannot mute participants on RFCs where they hold
authority. Slice 6's check uses "the muted user has a watches