Spaces:
Running
Running
| # Inspector β Auth, Permissions, Activity Rails | |
| Single source for identity, role gating, edit-lock, CSRF, admin endpoints, audit actor, activity rails. | |
| Code wins over prose. All paths relative to repo root. | |
| Auth code lives in `inspector/services/auth/` (package, not a flat `services/auth.py`). | |
| Flat shims re-export it: `from services import auth`, `from services import permissions`, `from services import predicates`, `from services import access`, `from services import hf_users`, `from services import public_activity`, `from services import activity_state` all resolve to the subpackage modules. | |
| --- | |
| ## Identity | |
| | Concept | Truth | | |
| |---|---| | |
| | Identity cookie | `inspector_session`, signed via `itsdangerous.URLSafeTimedSerializer` (key = `INSPECTOR_SESSION_SECRET`, salt `inspector-session-v1`) | | |
| | Cookie payload | `{login, hf_user_id, iat}` β **NO `role`, NO `csrf`** (`auth.py::encode_session`) | | |
| | Cookie max-age | `SESSION_COOKIE_MAX_AGE = 60*60*24*SESSION_COOKIE_MAX_AGE_DAYS` (default 30 days; override per-Space via `INSPECTOR_SESSION_MAX_AGE_DAYS`); enforced in `decode_session` via `loads(..., max_age=...)` | | |
| | Cookie flags (deployed) | `httponly=True`, `secure=True`, `samesite="None"` (iframe context, `INSPECTOR_BEHIND_PROXY=1`) | | |
| | Cookie flags (local) | `secure=False`, `samesite="Lax"` (plain HTTP rejects Secure) | | |
| | Role | Resolved **fresh per request** via `access.resolve_role(hf_user_id)` β never carried in cookie. Revoked maintainer β contributor on next request, no re-login | | |
| | OAuth-state cookie | Flask default `session` cookie (signed via `app.secret_key`), ~30s, used only between `/authorize` and `/callback` (Authlib internal) | | |
| | Canonical key | `hf_user_id` (OIDC `sub`, immutable). `login` is display-only, refreshable cache. Predicates/claims/dismissals/tombstones all key on `hf_user_id` | | |
| ### HF OAuth flow (`routes/auth/auth.py`, `services/auth/auth.py`) | |
| | Step | Route / fn | Behavior | | |
| |---|---|---| | |
| | init | `auth.py::init_oauth(app)` | Registers `huggingface` provider; `server_metadata_url` from `OPENID_PROVIDER_URL` (default `https://huggingface.co`); scope `OAUTH_SCOPES` (default `openid profile`); idempotent | | |
| | login | `GET /api/auth/login?return=<path>` | 503 if `is_oauth_configured()` false; stores `_safe_return_path` in flask session; `authorize_redirect(_callback_url())` | | |
| | callback | `GET /api/auth/callback` | exchanges code; reads `userinfo.sub` β `hf_user_id`, `preferred_username`/`name` β `login`; `encode_session(login, hf_user_id)`; sets cookie; 302 to safe return path | | |
| | logout | `POST /api/auth/logout` | clears cookie (`max_age=0`). Claims survive | | |
| | me | `GET /api/me` | `{login, hf_user_id, role, active_claim, active_claims, dev_mode}`; uniform null-filled shape when anonymous. `active_claim` = the **blocking** claim (open, not-marked-ready; via `repo_claims.open_claim_for_user`) β the field every FE one-claim gate keys off; marking ready releases the one-at-a-time hold so it drops to null. `active_claims` = the full under-review set assigned to the user (a marked-ready reciter stays in it; drives the picker's "mine" highlight) | | |
| | current user | `auth.py::current_user() -> User \| None` | reads cookie β `decode_session` β `resolve_role`; returns `User(hf_user_id, login, role)` frozen dataclass with live `role` | | |
| `is_oauth_configured()` = `OAUTH_CLIENT_ID` set AND `get_session_secret()` doesn't raise `MissingSecret`. | |
| `_safe_return_path` rejects absolute/protocol-relative URLs (open-redirect guard) β falls back to `/`. | |
| ### Local-dev identity (`auth.py::_dev_current_user`) | |
| | Concept | Truth | | |
| |---|---| | |
| | Gate | `is_dev_mode()` = `INSPECTOR_DEV_MODE == "1"` **and** `INSPECTOR_BEHIND_PROXY != "1"` (auto-set by `app.py` locally outside pytest). Forced off behind the deployed proxy; `app.py` aborts boot (`assert_dev_mode_safe`) if both are set, so the bypass can never run on a Space | | |
| | Cookie | `inspector_dev_role` (unsigned, only honored in dev mode), default `"owner"` | | |
| | Valid values | `DEV_ROLE_VALUES = ("owner","maintainer","contributor","anonymous")` | | |
| | `"anonymous"` | β `current_user()` returns `None` (explicit opt-out) | | |
| | Synthetic id | per-role: `hf_user_id = "dev-<role>"`, `login = "dev-<role>"` β distinct identity per role so switcher simulates distinct prod users (per-user dismissals/claims/audit scope correctly) | | |
| | Override | `INSPECTOR_DEV_<ROLE>_HF_ID` / `INSPECTOR_DEV_<ROLE>_LOGIN` env vars (set when local dev points at prod bucket so audit attributes to real HF user) | | |
| | Garbage cookie | falls back to `Role.OWNER` (don't 500 a dev page) | | |
| | Switcher route | `POST /api/dev/role` (`routes/auth/dev.py`) β `abort(404)` first if not dev mode; sets cookie 30-day, `samesite="Lax"` | | |
| --- | |
| ## Roles (`qua_shared/schemas/config/access.py::Role`) | |
| Tiers: `contributor < maintainer < owner`. (`Role.PIPELINE` exists but is never a user identity β only stamped on offline-extraction edit-history batches; never reaches the HTTP auth path.) | |
| Stored in **SQLite** `users` + `role_assignments` (`services/db/repo_access.py`), NOT JSON. `contributor` is implicit (any signed-in HF user with no active assignment β `Role.CONTRIBUTOR`); only `maintainer`/`owner` are explicit rows. Partial-unique index `ux_role_active` = one active role per user. Revoke is soft (`revoked_at`/`revoked_by`/`reason`). | |
| | Tier | Can do (cumulative) | | |
| |---|---| | |
| | contributor | sign in; claim `awaiting_review` row; edit own claim; mark/unmark own claim ready; release own claim; submit reciter request | | |
| | maintainer | + admin overrides (force-release, reassign, force-set-state, send-back/merge-rejected, request reject soft/hard); admin activity rail (redacted identity); per-user dismiss/undismiss; grant/revoke/update **maintainers**; HF user lookup | | |
| | owner | + grant/revoke/update **owners**; see actor identity on both rails; delete (tombstone) public-rail cards; **edit any public non-frozen reciter regardless of state, no claim**; **hold multiple simultaneous claims**; undiscard | | |
| Owner-asymmetry (`access.py`): only an OWNER can revoke/update an OWNER member (`permissions.is_owner(target) and not permissions.is_owner(actor)` β `NotAuthorized`). Granting OWNER requires OWNER; granting MAINTAINER requires MAINTAINER+. `revoke()` is atomic with cascade β releasing every open claim the user holds (`reciter.released` per slug via `state._apply_event`) in the same `durable_transaction`. | |
| `bootstrap(hf_user_id, login)` seeds the first OWNER into an empty table (refuses if any active member exists). | |
| The "Can do (cumulative)" table above is now the **default** for each tier, not a hardcoded ceiling β an owner can deviate per the capability system below. | |
| --- | |
| ## Capabilities (data-driven authorization) | |
| > **Canonical doc: [capabilities.md](capabilities.md)** β the full model + **the convention for adding a gate so it surfaces in the Permissions tab**. This section is the summary. | |
| The static tier model is now a **default baseline** over a data-driven capability matrix. An owner can toggle any capability on/off per tier (anonymous / contributor / maintainer) from the **Admin β Permissions** tab; the change is durable + audited and takes effect on the affected user's next request. **The resolver `can()` is the single source of authz** β every state handler, route, predicate, and edit-lock branch calls it. | |
| | Piece | Truth | | |
| |---|---| | |
| | Registry (data) | `qua_shared/schemas/config/capabilities.py` β `Capability{id, group, label, description, anon_eligible, owner_only_fixed, default_grants}` + `CAPABILITIES` tuple. `default_grants` encodes **today's exact behavior** (empty override table == legacy authz, guarded by `tests/services/test_capabilities.py::test_baseline_parity`). Backend-only; NOT codegen'd to the FE. | | |
| | Resolver (logic) | `services/auth/capabilities.py` β `tier_of(user_or_actor)` (`None`β`anonymous`), `resolve_grants() -> {(cap,tier): bool}` (baseline β overrides, cached on `db_seq`), `can(user_or_actor, cap)`, `capabilities_for(user)`. | | |
| | Override store | `permission_overrides(capability_id, tier, allowed, set_by, set_at)` (migration `0007`, `services/db/repo_permissions.py`). Stores **only deviations**; absent row β default; reset = `DELETE`. | | |
| | Cache | `services/storage/cache.py::{get,set,invalidate}_capability_matrix_cache` β single `(db_seq, matrix)` tuple. Any committed override write bumps `db_seq` β transparent invalidation (no restart). | | |
| **Invariants the resolver enforces regardless of the override table:** | |
| - **Owner is a superuser** β `can(owner, anything) is True`. | |
| - **`manage_permissions` is owner-only fixed** (`owner_only_fixed=True`) β never grantable to a lower tier even if a row is forced in. It gates the Permissions tab + endpoints; the recovery anchor (only owners can change permissions β the system can't be locked out). | |
| - **Anonymous only where `anon_eligible`** β action capabilities (need an identity/claim) are always `False` for anonymous; only the public-read caps (`view.catalog`, `view.public_activity`) are anonymous-toggleable. | |
| - **Structural integrity is NOT a capability** β the last-active-owner guard and owner-on-owner revoke asymmetry stay enforced (in `services/admin/users.py` / `services/auth/access.py`) even if `roles.*` is toggled on for maintainers. | |
| Capability groups (registry `group`): Public read & visibility Β· Reciter lifecycle Β· Requests & intake Β· Claims & review Β· Roles & access Β· Activity & moderation Β· Admin surfaces Β· Identity disclosure. Each gated transition maps to a capability via `services/state/state.py::_EVENT_CAPABILITY` (anti-drift test: `test_event_capability_map_is_complete_and_valid`). | |
| ### Enforcement entry points (all call `can()`) | |
| | Layer | Wrapper / call | Failure | | |
| |---|---|---| | |
| | State handlers | `state.py::_require_capability(actor, cap)` (inline in each `_h_*`, at the same point the legacy `_require_<role>` sat β 400-before-403 precedence preserved) | `NotAuthorizedForTransition` β 403 | | |
| | Route decorator | `utils/decorators.py::@require_capability(cap)` (injects `user`; anon allowed only for `anon_eligible` caps) | 401 anon / 403 wrong tier | | |
| | Route helper (inline) | `routes/_admin_helpers.py::require_capability_or_403` + `actions.py::_require_cap` | `(json, 403)` | | |
| | Edit-lock | `require_edit_lock` assignee path β `can(user, "segment.edit")`; admin-bypass arm β `can(user, "segment.edit_as_admin")` (owner branch unchanged) | `abort(403)` | | |
| | Redaction | route computes `can(caller, "identity.see_actor")` β bool into `public_activity.feed`/`requests` (services stay Flask-free) | identity redacted | | |
| | Access service | `access.py::_require_capability` β `roles.assign_maintainer` / `roles.assign_owner` (picker + `/api/admin/access/*` unified on the same caps) | `NotAuthorized` β 403 | | |
| | FE (global) | `/api/me.capabilities[]` + `lib/stores/capabilities.ts::can(id)` / `hasCapability(user,id)` | hides/disables UI | | |
| > **Residual (intentional):** `reciter.released` keeps `_require_claim_holder_or_maintainer` (self-release is ownership; a maintainer releasing another's claim via `/api/release` is pre-existing behavior with its own test). FE gating migration so far covers the activity rail (`ActivityRail.svelte` β `activity.delete` / `identity.see_actor`); the segments editor (`editing-mode.ts`) + requests/reviews drawers still gate on role in the UI β the backend enforces the capability regardless, so a revoked maintainer's action 403s rather than being hidden. | |
| Endpoints: `GET /api/admin/permissions` (grouped matrix) + `POST /api/admin/permissions/<cap>/<tier>` (`{allowed}` or `{reset:true}`), both `@require_capability("manage_permissions")`; POST stacks `@require_same_origin`, instant, audited (`access.permission_changed`, a `HIDDEN_EVENTS` audit-only event). Service: `services/admin/permissions.py`. UI: see [admin-dashboard.md](admin-dashboard.md). | |
| --- | |
| ## Predicates | |
| ### `services/auth/permissions.py` β pure role + reason (Flask-free, exception-free except programmer error) | |
| | Predicate | Returns | | |
| |---|---| | |
| | `role_of(obj)` | `Role` enum; normalizes str/enum/`Member`/`User`/`Actor`. Raises `ValueError` on malformed (= bug) | | |
| | `is_owner(obj)` | `role == OWNER` | | |
| | `is_maintainer(obj)` | `role β {MAINTAINER, OWNER}` (elevated tier) | | |
| | `is_contributor_or_higher(obj)` | `role β {CONTRIBUTOR, MAINTAINER, OWNER}` (sanity vs anonymous) | | |
| | `has_role(obj, *allowed)` | membership test | | |
| | `is_claim_holder(obj, row)` | `row.assignee_hf_id == obj.hf_user_id` (canonical id; False if no assignee) | | |
| | `is_claim_holder_or_maintainer(obj, row)` | `is_maintainer(obj) or is_claim_holder(obj, row)` | | |
| | `normalize_reason(raw, min_chars=MIN_REASON_CHARS)` | trimmed reason β₯ min, else `None` | | |
| `MIN_REASON_CHARS = 10`. Re-exported by `routes/_admin_helpers.py`. | |
| ### `services/auth/predicates.py` β reciter-task predicates (drive `/api/reciter-task/<slug>` response; backend re-checks on mutate) | |
| Anonymous (`user is None`) β all `False`. | |
| | Predicate | Condition | | |
| |---|---| | |
| | `can_claim` | `state==AWAITING_REVIEW` + `visibility==PUBLIC` + signed in + (owner OR `not has_other_active_claim`) | | |
| | `can_edit` | `state==UNDER_REVIEW` + `not marked_ready` + `PUBLIC` + `is_claim_holder` | | |
| | `can_edit_as_admin` | maintainer/owner + `UNDER_REVIEW` + `not marked_ready` + `PUBLIC` (any assignee) | | |
| | `can_edit_as_owner` | owner + `not marked_ready` + `PUBLIC` (any state) | | |
| | `can_mark_ready` | `can_edit` + `not marked_ready` | | |
| | `can_skip_mark_ready_gates` | `can_mark_ready` + `can("claim.mark_ready_skip_gates")` β owners by default; other tiers only if owner-granted via the Permissions tab. FE branches `onMarkReady` on this: when true, POSTs an empty body to `/api/mark-ready/<slug>` and skips the form modal entirely. | | |
| | `can_unmark_ready` | `UNDER_REVIEW` + `marked_ready` + `is_claim_holder` | | |
| | `can_release` | `UNDER_REVIEW` + `not marked_ready` + `is_claim_holder` | | |
| Note on `can_release`: once the reviewer has marked the row ready, self-release is blocked at both the predicate AND the state handler (`_h_released` raises `InvalidTransition("release blocked: unmark ready first, or ask an admin to send back")` when the claim holder tries it). Admin force-release / send-back via the Reviews drawer remains available. The segments-tab footer hides Claim / Mark Ready / Unclaim entirely after mark-ready and surfaces only a passive "Marked ready" status pill. | |
| `build_predicates(row, user, *, has_other_active_claim)` returns the full map; route handlers call it (no drift). | |
| --- | |
| ## Where authorization is enforced | |
| **Tier authorization routes through the capability resolver `can()`** β see the *Enforcement entry points* table in [Β§ Capabilities](#capabilities-data-driven-authorization) above and [capabilities.md](capabilities.md). The remaining **non-capability** gates: | |
| | Layer | Wrapper / call | Failure | | |
| |---|---|---| | |
| | Ownership (claim holder) | `permissions.is_claim_holder` / `is_claim_holder_or_maintainer` in state handlers + edit-lock | "is this *your* row" β NOT a capability; `NotAuthorizedForTransition`β403 / `abort(403)` | | |
| | Edit-lock (state/visibility) | `utils/decorators.py::require_edit_lock(admin_bypass=)` β owner bypass + `UNDER_REVIEW`/`not marked_ready`/`PUBLIC` preconditions; the **tier** dimension delegates to `can("segment.edit" / "segment.edit_as_admin")` | `abort(401/403/404)` | | |
| | CSRF | `utils/decorators.py::require_same_origin` | `abort(403)` | | |
| | Structural integrity | last-active-owner guard + owner-on-owner revoke asymmetry (`services/admin/users.py`, `services/auth/access.py`) β enforced regardless of `roles.*` toggles | `LastOwnerError`β409 / `NotAuthorized`β403 | | |
| | Frontend | `lib/stores/capabilities.ts::can(id)` / `hasCapability(user,id)` (capability-aware); `isOwner` still gates the owner-only Permissions tab itself | hides/disables UI | | |
| `@require_role` / `require_role_or_403` still exist (generic, unit-tested) but have **no route call-sites** β the guard test `tests/test_capability_convention.py` forbids `@require_role` in `routes/`. | |
| **First-edit guide gate (FE-only onboarding).** `syncEditingMode` returns | |
| `viewReason: 'guides_unread'` before **any** editable kind (owner/editor/ | |
| maintainer) whose `currentUser.guides_read` doesn't yet cover | |
| `REQUIRED_GUIDE_KEYS`. Applies to **all editing roles** β no dev-mode or admin | |
| exemption. The `editGate` action opens `GuidesGateModal` instead of the usual | |
| popover; keyboard edit shortcuts (`E`/`S`/`Enter`) honour the same check via | |
| `gateKeyboardEdit()` in `tabs/segments/utils/keyboard.ts`. Reading every guide | |
| lifts the gate. This is UX, **not** authorization β there is no backend | |
| enforcement (`require_edit_lock` is unchanged); a user who bypasses the FE still | |
| can't save anything they lack `segment.edit` for. Read marks persist in | |
| `guide_views` and surface on `/api/me` as `guides_read`. See | |
| [`accordion-guides.md`](accordion-guides.md). | |
| **New gated action** = add a `Capability` to the registry + gate via `can()` / `@require_capability` / `_require_capability`. Never hardcode `Role(...) in (...)` or `is_maintainer()` at a new gate β route it through the resolver so it's owner-toggleable and surfaces in the Permissions tab. Full recipe: [capabilities.md Β§ Adding a capability](capabilities.md). | |
| --- | |
| ## Edit lock (`utils/decorators.py::require_edit_lock(reciter_param="reciter", *, admin_bypass=False)`) | |
| Order of refusals (first match wins): | |
| 1. `current_user() is None` β `abort(401)` "authentication required" | |
| 2. missing `slug` in route kwargs β `abort(400)` | |
| 3. `state_service.get_row(slug) is None` β `abort(404)` "unknown reciter" | |
| 4. **owner branch** (`is_owner(user)`): bypasses state check; still refuses `marked_ready` (403 frozen) and `visibility != PUBLIC` (403) | |
| 5. **non-owner branch**: `state != UNDER_REVIEW` β 403; `marked_ready` β 403; `visibility != PUBLIC` β 403; then `is_assignee = is_claim_holder` OR `is_admin = admin_bypass and is_maintainer` β neither β 403 "not editable by this user" | |
| 6. On success sets `g.current_user`, `g.current_row` (route builds `Actor` without re-resolving) | |
| `admin_bypass=True` lets maintainer/owner edit any `UNDER_REVIEW` row regardless of assignee. Owner bypass needs no flag. `marked_ready` rows are NEVER editable via this gate (reviewer's "continue editing" flips `marked_ready=False` first). | |
| Claim/release/mark/unmark routes (`routes/claims/claims.py`) do **not** use `require_edit_lock` β `state.transition` handlers own ownership + precondition checks. | |
| --- | |
| ## CSRF + same-origin (`utils/decorators.py::require_same_origin`) | |
| | Method | Behavior | | |
| |---|---| | |
| | GET/HEAD/OPTIONS | always allowed (no mutation) | | |
| | POST/PUT/DELETE | require `Origin` (preferred) or `Referer` whose scheme+netloc match `request.scheme`+`request.host`; mismatch β `abort(403)`; both headers missing β `abort(403)` | | |
| Defense-in-depth on top of `SameSite` cookie. Stack `@require_same_origin` above `@require_role` on every mutating admin/claim route. | |
| --- | |
| ## Admin endpoints by tier | |
| `@require_role` requires signed-in + tier; mutating routes also stack `@require_same_origin`. Reason validated by `_admin_helpers.validate_reason` (β₯`MIN_REASON_CHARS`=10 β else 400) unless noted. | |
| | Endpoint | Required role | Reason β₯10 | Audit event | Source | | |
| |---|---|---|---|---| | |
| | `POST /api/claim/<slug>` | signed-in (contributor+) | β | `reciter.claimed` | `routes/claims/claims.py` | | |
| | `POST /api/release/<slug>` | signed-in | β | `reciter.released` | claims.py | | |
| | `POST /api/mark-ready/<slug>` | signed-in | β | `reciter.marked_ready` β body is a `MarkReadyRequest` (6-key checklist + 2 optional comments) and the handler also re-computes the 6 blocking validation counts. Holders of `claim.mark_ready_skip_gates` (owners by default) may POST an empty body to skip both gates; the handler stamps `bypass_used=True` on the persisted submission. | claims.py | | |
| | `POST /api/unmark-ready/<slug>` | signed-in | β | `reciter.unmarked_ready` | claims.py | | |
| | `GET /api/reciter-task/<slug>` | open | β | (read; predicates) | claims.py | | |
| | `POST /api/reciter/<slug>/request` | contributor+ | β | `reciter.requested` | `routes/claims/requests.py` | | |
| | `GET /api/admin/request/<slug>` | maintainer+ | β | (read; owner sees requester identity) | requests.py | | |
| | `POST /api/admin/request/<slug>/reject-soft` | maintainer+ | yes | `reciter.request_rejected_soft` | requests.py | | |
| | `POST /api/admin/request/<slug>/reject-hard` | maintainer+ | yes | `reciter.request_rejected_hard` | requests.py | | |
| | `POST /api/admin/reciter/<slug>/undiscard` | **owner** | yes | `reciter.undiscarded` | requests.py | | |
| | `POST /api/admin/claim/force-release/<slug>` | maintainer+ | yes | `claim.force_released` | `routes/admin/actions.py` | | |
| | `POST /api/admin/claim/reassign/<slug>` | maintainer+ | yes | `claim.reassigned` | actions.py | | |
| | `POST /api/admin/state/force-set/<slug>` | maintainer+ | yes | `admin.force_set_state` | actions.py | | |
| | `POST /api/admin/send-back/<slug>` | maintainer+ | yes | `reciter.merge_rejected` | actions.py | | |
| | `POST /api/admin/users/lookup` | maintainer+ | β | (no mutation; HF proxy) | actions.py | | |
| | `POST /api/admin/access/grant` | maintainer+; OWNER role β owner | yes | `access.role_granted` | `routes/admin/access.py` | | |
| | `POST /api/admin/access/revoke` | maintainer+; OWNER target β owner | yes | `access.role_revoked` (+ cascade `reciter.released`) | access.py | | |
| | `POST /api/admin/access/update` | maintainer+; OWNER role β owner | optional (β₯10 if present) | `access.role_updated` | access.py | | |
| | `GET /api/admin/activity` | maintainer+ | β | (read; `no-store`) | `routes/admin/activity.py` | | |
| | `POST /api/admin/activity/dismiss` | maintainer+ | β | `activity.dismissed` | activity.py | | |
| | `POST /api/admin/activity/undismiss` | maintainer+ | β | `activity.undismissed` | activity.py | | |
| | `DELETE /api/public/activity/<audit_id>` | **owner** | yes | `admin.activity_deleted` | activity.py | | |
| | `GET /api/public/activity` | open | β | (read; owner gets actor identity; `no-store`) | `routes/public/public.py` | | |
| `access.grant`/`update` enforce OWNER-tier checks twice β `_require_role_or_403` at route for a clean 403, and again in `services/access.py` (`NotAuthorized` β 403). `reassign` resolves `to_login` via `hf_users.lookup` (HF public `/api/users/<login>/overview`) β canonical `hf_user_id` before persisting; lookup failure β 502, unknown login β 400. | |
| --- | |
| ## Audit actor (`qua_shared/schemas/config/audit.py`) | |
| `AuditRecord{ts, event, slug?, from_state?, to_state?, actor, payload, request_id?, reason?, result}`. | |
| `Actor{hf_user_id, login_at_time, role}` (`use_enum_values=True`). | |
| | Field | Truth | | |
| |---|---| | |
| | `hf_user_id` | canonical id; immutable | | |
| | `login_at_time` | snapshot of cookie login at write time β **never refetched**; preserves historical login after rename | | |
| | `role` | frozen at action time | | |
| Built by `_admin_helpers.actor_for(user)` = `Actor(hf_user_id=user.hf_user_id, login_at_time=user.login, role=role_of(user).value)`. | |
| Reason discipline: admin mutations require reason β₯10 chars (`normalize_reason` `None` β 400), persisted on `AuditRecord.reason`. No wildcard "manual override" β new recovery scenarios get a new named event with its own reason check. | |
| --- | |
| ## Activity rail | |
| One feed β the anonymous-visible Recent Activity rail β a projection of the SQLite `transitions` log (`repo_transitions`), classified by `services/activity/activity_classification.py`. The admin notifications rail was retired; admin awareness now lives in the **Admin dashboard** tabs (Users / Requests / Reviews) β see [admin-dashboard.md](admin-dashboard.md). | |
| | Bucket | Surfaced where | Identity | | |
| |---|---|---| | |
| | `public` | anonymous-visible Recent Activity rail | redacted; owner caller sees `actor_login`/`actor_hf_user_id` | | |
| | `hidden` | nowhere passive (bulk/operational, role administrivia, former admin-only events, feedback-loop events) | β | | |
| `classify(record)` returns `public` or `hidden`; unknown β `hidden` (safe default β new events stay off the rail until explicitly classified). Pre-event records with `to_state == "awaiting_alignment"` β public `requested` kind. Anti-drift test: every event in `services/state.py:_HANDLERS` must appear in `PUBLIC_EVENTS` or `HIDDEN_EVENTS`. | |
| | Feed | Service | Route | Pagination | Window | | |
| |---|---|---|---|---| | |
| | public | `services/activity/public_activity.py::feed` | `GET /api/public/activity` | `cursor`/`limit` (1β500, default 50) | rolling 2 months | | |
| Filters `result != "ok"` records. `audit_id` = STORED transition `content_hash` (never recomputed β a migration-NULLed slug would mismatch existing tombstones; `activity_classification.audit_id()` is the fallback derivation only). | |
| ### Global tombstones (`services/db/repo_activity.py`, `services/activity/activity_state.py`) | |
| Owner-only mutation against the activity sidecars: `DELETE /api/public/activity/<audit_id>` writes a row into `activity_tombstones` (`audit_content_hash`, `deleted_by_id`, `reason`, `ts`) so the public feed filters the referenced audit record out for everyone. Reason β₯10 chars; `@require_same_origin` on top of `@require_role(OWNER)`. | |
| `activity_state.delete(audit_id, actor, reason)` upserts the row + appends the `admin.activity_deleted` transition in one `durable_transaction`. Idempotent (`ON CONFLICT β¦ DO UPDATE`). `activity_state.snapshot()` returns `ActivityState{deleted}` (per-user dismissals were dropped with the admin notifications rail). | |
| ### Other activity-package modules | |
| | Module | Purpose | Route | | |
| |---|---|---| | |
| | `services/activity/history_query.py` | Segments-tab edit-history parse/filter/summary; `build_split_group_index`, `build_resolved_by_edit_index`; cache-aware via `services/storage/cache`. NOT audit-log activity | `GET /api/seg/edit-history/<reciter>` (`routes/segments/validation.py`) | | |
| | `services/activity/stats.py` | Segmentation histograms/percentiles from `detailed.json`/seg verses; pure dicts | `GET /api/seg/stats/<reciter>` (validation.py) | | |
| | `services/activity/search_normalize.py` | Arabic-aware `normalize_arabic`/`matches`; lockstep with FE `SearchableSelect.svelte` | backs `?search=` filters | | |