Files
ohm-content/specs/rfcs/2026-06-08-scoped-admin-ia.md
T
ben.stull 5d0cadda3b
regenerate-readme-index / noop (push) Has been cancelled
Sync scoped-admin-ia entry to v0.1.2 (§4.5 collection discovery)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:19:36 -07:00

576 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
slug: 2026-06-08-scoped-admin-ia
title: "Scoped Admin Information Architecture"
state: super-draft
id: null
repo: null
proposed_by: ben.stull@wiggleverse.org
proposed_at: '2026-06-08'
graduated_at: null
graduated_by: null
owners:
- ben.stull@wiggleverse.org
arbiters: []
tags:
- solution-design
---
# Solution Design: Scoped Admin Information Architecture
| | |
| --- | --- |
| **Author(s)** | Ben Stull |
| **Reviewers / approvers** | Ben Stull |
| **Status** | `draft` |
| **Version** | v0.1.2 |
| **Source artifacts** | Product user stories (session OHM-0090.0) · Binds onto [`2026-06-05-three-tier-projects-collections.md`](./2026-06-05-three-tier-projects-collections.md) (§22) now merged into `SPEC.md` §22 · Resolves decision **D8** of [`2026-06-06-configurable-collection-metadata.md`](./2026-06-06-configurable-collection-metadata.md) |
**Change log**
| Date | Version | Change | By |
| --- | --- | --- | --- |
| 2026-06-08 | v0.1.0 | Initial draft from brainstorming session OHM-0090.0 | Ben Stull |
| 2026-06-08 | v0.1.1 | G-3 resolved (warn+allow); admin surfaces kept type-agnostic (G-11); §9 LLM authoring-context **config** surfaces added (consumption split to a separate spec, G-12) | Ben Stull |
| 2026-06-08 | v0.1.2 | §4.5 prompt collection discovery (content-repo webhook → `refresh_registry`, parity with create-collection API; D-DISCOVER), surfaced by the dogfood; added to S1 | Ben Stull |
---
## 1. Context
*The business lens is intentionally light: this design's solution is known up front — it is admin functionality inside the RFC app. The inputs are **product** user stories, not business outcomes. §1 frames the problem; the weight is in §3–§5.*
### 1.1 Executive summary
§22 delivered the *authority* model for the three-tier world — unified roles
`{owner, contributor}` attached at any of three scopes `{global, project,
collection}`, composing downward by a most-permissive union (`SPEC.md` §22.6,
§22.7). What it did **not** deliver is the *administration surface* for that
world. The admin **page** is still single-tier: `/admin` is mounted only at
deployment scope, and it mixes grains that belong to different tiers. Worse,
the management actions that *do* exist (create project, create collection,
assign owners) are reachable only through directory pages that the single-item
(`N=1`) landing redirects skip past — so on a typical one-project,
one-collection deployment an Owner has **no UI path** to manage or grow their
own deployment. The value at stake: an Owner at every tier can configure and
grow exactly the scope they own, from a surface that matches the tier they're
standing in — with no API-only dead-ends.
### 1.2 Background — the four defects
| # | Defect | Evidence |
| --- | --- | --- |
| DEF-1 | `/admin` is mounted only at deployment scope | `App.jsx:349` (`/admin/*``AdminWithSidebar`); no `/p/<id>/admin` or `/p/<id>/c/<cid>/admin` route exists |
| DEF-2 | Retired RFCs — a **collection-grained** concept — live in the global, site-owner-only admin, unfiltered across collections | `Admin.jsx:54` (`RetiredTab`, owner-gated); `GET /api/admin/retired-rfcs` lists `cached_rfcs WHERE state='retired'` with **no collection filter** (`api_admin.py:834`); `cached_rfcs` PK is `(collection_id, slug)` (mig 029) |
| DEF-3 | Graduation queue is collection-keyed data shown at site level; the frontend already pulls `useProjectId()` but only for links, never to scope the query | `Admin.jsx:723` (`useProjectId`); `GET /api/admin/graduation-queue` lists `cached_rfcs WHERE state='super-draft'` unfiltered (`api_admin.py:789`) |
| DEF-4 | Create-project / create-collection work but are stranded behind directories the `N=1` landing skips | Buttons live in `Directory.jsx:33/43` and `CollectionDirectory.jsx:66/75`; `DeploymentLanding` redirects past `Directory` when `projects.length===1` (`App.jsx:448`); `CollectionDirectory` redirects past itself when `cols.length===1` (`CollectionDirectory.jsx:45`) |
A fifth, related gap: **decision D8** of the configurable-collection-metadata
design deferred in-app editing of a collection's `fields:` schema (edit
`.collection.yaml` in git for v1). That is logically a Collection-Admin
capability and is folded in here.
### 1.3 Actors / roles
The §22 unified roles, named as the personas this design serves. Authority
**inherits downward** and composes as a most-permissive union (§22.7), so each
row below *is also* every row beneath it within its subtree.
| Role | Scope | Inherits |
| --- | --- | --- |
| **Global Owner** (incl. deployment `owner`/`admin`) | deployment | is a Project Owner of every project, and a Collection Owner of every collection |
| **Project Owner** | one project | is a Collection Owner of every collection in that project (incl. future ones) |
| **Collection Owner** | one collection | — |
| **Contributor** | global / project / collection | propose entries; at project/global scope may also create collections |
| **Requester** | (no role yet) | someone holding a private URL who can request access |
This inheritance is **not new** — it is exactly what `effective_role_at_scope`,
`is_project_superuser`, and `is_collection_superuser` already compute
(`auth.py:407`, `:598`, `:658`). This design only *surfaces* it; it changes no
resolution logic.
### 1.4 Non-goals (this design)
- No change to the §22.7 role-resolution algorithm.
- No per-scope **audit log** or **permission events** (left deployment-wide; see §6).
- No **post-create rename** of a project or collection slug (create-time only; §3.10, decision D-SLUG).
- No collection living in a **different repo** than its project's content repo (path within the project repo only; §3.11, decision D-STORE).
---
## 2. Product — user stories & traceability
The session's product user stories, each given an ID and traced to the surface
that satisfies it (§3) and the decision that shaped it (§5).
| # | User story | Satisfied by | Decision |
| --- | --- | --- | --- |
| US-1 | As a global owner, I want to create a project | Site admin → **Projects** panel (§3.4.1); scope switcher "+ New project" | — |
| US-2 | As a project owner, I want to create an RFC collection and assign owners | Project admin → **Collections** panel (§3.4.2) | — |
| US-3 | As a project owner, I want to configure the project slug (URL) while creating it | Create-project form (already collects `project_id`); §3.10 | D-SLUG |
| US-4 | Global owners are project owners for all projects | §1.3 inheritance — no change (§22.7) | — |
| US-5 | Project owners are collection owners for all collections in their project | §1.3 inheritance — no change (§22.7) | — |
| US-6 | As a collection owner, I want to configure the metadata for my collection | Collection admin → **Fields** panel (D8 resolution, §3.12) | D8 |
| US-7 | As a collection owner, I want to configure the collection slug (URL) | Create-collection form (already collects `collection_id`); §3.10 | D-SLUG |
| US-8 | As a collection owner, I want to configure the default starting state for new documents | Collection admin → **Settings** (`initial_state`, §3.4.3) | — |
| US-9 | As a collection owner, I want to configure the repo & path where my collection is stored | Collection admin → **Settings** (path within project repo, §3.11) | D-STORE |
| US-10 | As a collection owner, I want the path auto-created if it doesn't exist | Bot provisioning on create / settings save (§3.11) | D-STORE |
| US-11 | As a collection owner, I want to retire my entire collection so it's hidden from the project list | Collection admin → **Danger zone** (new `retired` state, §3.13) | D-RETIRE |
| US-12 | As a project owner, I want to mark my project Private so it's hidden from the everyone-list | Project admin → **Settings** (visibility `gated`, §3.8) | D-PRIVATE |
| US-13 | As a project owner, I want collections in a private project to be private by default | Create-collection default + runtime clamp (§3.9) | D-PRIVATE |
| US-14 | As a URL-holder of a private collection, I want to share it and let people request access | Request-access page on gated resources (§3.8) | D-PRIVATE |
| US-15 | As a member of a private project/collection, I want it to show in the project nav dropdown | Scope switcher includes member-visible private scopes (§3.3) | — |
---
## 3. Solution — the scoped admin IA
### 3.1 The model: one admin scope per tier
Three administration surfaces, one per tier, each owning **exactly** its grain
and nothing above or below it:
```
deployment ──▶ Site admin /admin
project ──▶ Project admin /p/<pid>/admin
collection ▶ Collection admin /p/<pid>/c/<cid>/admin
```
The guiding rule: **a surface lives at the tier whose data it mutates.** A
panel that edits collection-keyed data (retired entries, the graduation queue,
metadata fields) lives in Collection admin even if a Project or Global Owner —
who *inherit* collection authority — is the one using it.
### 3.2 Routing
| Route | Shell | Gate |
| --- | --- | --- |
| `/admin/*` | Site admin (existing `Admin.jsx`, slimmed) | `viewer.role ∈ {owner, admin}` *or* global Owner |
| `/p/<pid>/admin/*` | **new** `ProjectAdmin` (inside `ProjectLayout`) | `is_project_superuser(viewer, pid)` |
| `/p/<pid>/c/<cid>/admin/*` | **new** `CollectionAdmin` | `is_collection_superuser(viewer, cid)` |
Conventions, learned from the recent sidebar-nav fix (commit `8e207a6`): tab
rails use **absolute** `NavLink` paths with `end`, never relative links that
accumulate. Add path builders to `entryPaths.js`: `siteAdminPath()`,
`projectAdminPath(pid)`, `collectionAdminPath(pid, cid)`.
`ProjectAdmin` reads its scope from `useProject()` (the `ProjectContext`
provider, `ProjectLayout.jsx:20`). `CollectionAdmin` reads `useCollectionId()`
plus a collection fetch for the `viewer.role` flag (the pattern
`CollectionDirectory.jsx` already uses).
### 3.3 Reachability — the two always-present entry points (fixes DEF-4)
We **do not** special-case the `N=1` redirects. Instead we add two entry points
that are present regardless of how many projects/collections exist:
1. **Scope switcher** (top nav dropdown — satisfies US-15). Lists every project
the viewer can access and, under the current project, its collections —
*including private (gated) ones the viewer is a member of*, because
`/api/deployment` `projects[]` and `/api/projects/:id/collections` already
filter to caller-visible scopes and a member can read a gated scope (§22.5).
Owners additionally see a per-row **Manage** (gear → that scope's admin) and,
at the bottom, **+ New project** (`can_create_project`) / **+ New
collection** (`can_create_collection`).
2. **Gear / "Manage" affordance** on each project landing (`<Welcome/>` / the
project directory) and collection landing, shown to owners of that scope,
linking to the scoped admin.
Because the gear sits on the very page the `N=1` redirect *lands the Owner on*,
the dead-end disappears with no change to `DeploymentLanding` /
`CollectionDirectory` redirect logic. The existing directory create-buttons stay
(reachable when `N>1`); they become a second path, not the only one.
### 3.4 Per-scope surface inventory
#### 3.4.1 Site admin — `/admin` (deployment grain only)
| Panel | Status | Notes |
| --- | --- | --- |
| Users | unchanged | role / write-mute / permission_state |
| Allowlist | unchanged | private-beta OAuth gate |
| Audit log | unchanged | deployment-wide (future scoping — §6) |
| Permission events | unchanged | deployment-wide |
| **Projects** | **new** | list all projects; **create project** (US-1); mark a project **Private** (US-12) |
| ~~Graduation queue~~ | **removed** | re-homed to Collection admin (§3.6) |
| ~~Retired~~ | **removed** | re-homed to Collection admin (§3.6) |
#### 3.4.2 Project admin — `/p/<pid>/admin`
| Panel | Notes |
| --- | --- |
| **Settings** | project name; **visibility incl. Private** (US-12); private cascade default (§3.9) |
| **Collections** | list; **create collection & assign owners** (US-2); per-collection quick links to its admin |
| **Members** | project-scope grants + grant collection-scope (existing scope-aware `POST /api/projects/:pid/members`, `api_memberships.py`) |
| **Graduation (rollup)** | read-only union across the project's collections; convenience view (canonical surface is per-collection) |
#### 3.4.3 Collection admin — `/p/<pid>/c/<cid>/admin`
| Panel | Notes |
| --- | --- |
| **Settings** | name; **default starting state** `initial_state` (US-8); **storage path** within project repo (US-9/10, §3.11); visibility |
| **Fields** | in-app `fields:` schema editor — **resolves D8** (US-6, §3.12) |
| **Members** | collection-scope grants |
| **Graduation queue** | re-homed here, filtered to this collection (§3.6) |
| **Retired entries** | re-homed here, filtered to this collection; un-retire (§3.6) |
| **Danger zone** | **Retire collection** (US-11, new `retired` state, §3.13) |
### 3.5 Permission gating — every surface on the four-layer composition
Each surface gates on an existing `auth.py` primitive; **no new authorization
logic** is introduced. Backend gates are authoritative; the frontend mirrors
them from the `viewer` sub-object already embedded in API responses
(`/api/auth/me`, the collection/collections GETs).
| Surface / action | Backend gate | Frontend flag source |
| --- | --- | --- |
| Site admin | `viewer.role ∈ {owner,admin}` | `/api/auth/me``viewer.role` |
| Create project (US-1) | `can_create_project` (`auth.py:721`, global Owner only) | `/api/deployment``viewer.can_create_project` |
| Project admin | `is_project_superuser` (`auth.py:407`) | `/api/projects/:id/collections``viewer.role==='owner'` |
| Create collection (US-2) | `can_create_collection` (`auth.py:701`) | `viewer.can_create_collection` |
| Project/collection invite | `can_invite_at_project` / `can_invite_at_collection` (`auth.py:740/751`) | `viewer.can_invite` |
| Collection admin | `is_collection_superuser` (`auth.py:658`) | `/api/projects/:id/collections/:cid``viewer.role==='owner'` |
| Edit collection fields (US-6) | `is_collection_superuser` | `viewer.role==='owner'` |
| Retire collection (US-11) | `is_collection_superuser` | `viewer.role==='owner'` |
| Request access (US-14) | `can_request_join` (§22.8) | `viewer.can_request_join` |
### 3.6 Re-homing retired & graduation (fixes DEF-2, DEF-3)
Both are collection-keyed (`cached_rfcs` PK `(collection_id, slug)`). Their
canonical home becomes **Collection admin**, scoped to one collection:
- **New** `GET /api/projects/:pid/collections/:cid/graduation` → the existing
ready/blocked split (owners-set + zero-blocking-PRs), `WHERE collection_id = :cid`,
gated `is_collection_superuser`. The old `/api/admin/graduation-queue` is
retained only to back the **project rollup** (re-gated to project superuser,
filtered to the project's collections) and the panel removed from Site admin.
- **New** `GET /api/projects/:pid/collections/:cid/retired` → `WHERE state='retired'
AND collection_id=:cid`, gated `is_collection_superuser`.
- **Un-retire becomes collection-scoped**: `POST
/api/projects/:pid/collections/:cid/rfcs/:slug/unretire`, replacing the
slug-only owner-gated `POST /api/rfcs/:slug/unretire` (`api_graduation.py:548`).
This closes a real bug: the slug-only route is **collection-ambiguous** when
the same slug exists in two collections (the same class of bug noted for
`resolve_models_for_rfc`). See §4.4.
### 3.7 (reserved)
### 3.8 Private + request-access (US-12, US-14 — decision D-PRIVATE)
Today `gated` returns a bare **404** to non-members (`SPEC.md` §22.5; read gate
in `auth.py:635`). We refine this so a private resource is shareable by URL:
- **Signed-in non-member** hitting a direct URL to a `gated` project/collection
→ a **request-access page** (the resource's display *name* is revealed,
content is not), wired to the existing §22.8 request-to-join flow
(`join_requests`, mig 032) and the owner's existing cross-collection inbox.
- **Anonymous** visitor → redirect to login, *then* the request-access page.
Existence is never revealed to an anonymous caller.
- **Directories** still omit gated scopes (unchanged); existence is revealed
only to someone who already holds the URL **and** has signed in.
Backend: the gated read gate returns a distinguishable **403 + `{name,
can_request_join}`** for a signed-in non-member, vs **404** for genuinely-absent
ids and for anonymous callers. This is a **normative amendment to §22.5** (§4.1)
— the only binding change in this design.
### 3.9 Private-by-default cascade (US-13 — decision D-PRIVATE)
Marking a project Private (`gated`) affects collections two ways:
1. **New** collections default their create-form visibility to `gated`.
2. **Existing** collections need *no* data change: §22.5's keystone already
makes effective visibility the **stricter** of project and collection
("stricter wins"). A child stored `public` under a `gated` parent therefore
*reads* as gated at runtime. We document this and surface it in the
Collections panel ("effective: Private (inherited)") so it isn't surprising.
### 3.10 Slug configuration (US-3, US-7 — decision D-SLUG: create-time only)
Project (`project_id`) and collection (`collection_id`) slugs are the URL id and
are **set at creation** — already collected by `CreateProjectModal` /
`CreateCollectionModal`. This design adds **no post-create rename**: a rename
would break live URLs and re-key `cached_rfcs`, demanding redirect machinery
out of scope here. The Settings panels show the slug **read-only** with a "set
at creation" note. (Post-create rename-with-redirects is a candidate future
slice — §6.)
### 3.11 Collection storage config + auto-provision (US-9, US-10 — decision D-STORE)
Scope (D-STORE): a collection's content lives in **its project's content_repo**;
the collection owner configures the **path (subfolder)** within that repo, and
the path is **auto-created** if absent. No different-repo override.
- Today a collection's `.collection.yaml` lives at a conventional subfolder
(`<collection_id>/`); the registry maps the collection by that convention.
This design records an **explicit `path`** for the collection in the manifest
/ registry so path and slug are decoupled, defaulting to `<collection_id>/`.
- "Auto-create if it doesn't exist": creating a collection already writes
`.collection.yaml` at its path (so the path is provisioned by the create
commit). For a settings-time path that points somewhere new, the bot writes
the manifest (and a `.gitkeep` if needed, since git has no empty dirs) at the
new path. **Moving** an existing collection's files between paths is out of
scope (same rationale as slug rename) — path is settable but the *move* of
existing entries is deferred (§6); see gap G-4.
### 3.12 Metadata field-schema editor (US-6 — resolves D8)
The configurable-collection-metadata work shipped entry-metadata *value* editing
(contributor-gated) but deferred the *schema* editor (D8). This adds it:
- **New** owner-gated write path that commits the `fields:` block of
`.collection.yaml` via the bot, then triggers a registry refresh — the same
git-is-truth → mirror loop collection-create uses. **No** direct write to
`collections.config_json` (the mirror stays derived).
- Reuses `metadata_schema.parse_fields` / `validate` (already lenient,
advisory) for in-form validation; field types `enum` / `tags` / `text` (v1),
matching the value editor.
- **Destructive-edit handling** (decision G-3: *warn + allow*): removing a field
or narrowing an enum can orphan existing entry values. The editor **shows the
usage count and warns, but allows** the edit; orphaned sidecar values are
*kept but ignored* (consistent with the existing `metadata_malformed` advisory
flag), and the Owner-gated `metadata.migrate_collection` endpoint (shipped in
metadata SLICE-4) is offered to normalize. No hard block on removing an in-use
field — re-adding the field restores its values.
### 3.13 Collection retire (US-11 — decision D-RETIRE: distinct state)
A new **`retired` collection state**, mirroring per-entry retire (§13.7):
- **Hidden** from the project's collection list and the scope switcher.
- **Read-only**: write endpoints (propose, metadata edit, etc.) refuse for a
retired collection.
- **Reversible**: an Owner can un-retire from Collection admin.
- Members can still reach it by direct URL (read-only) — we hide, not block
reads (the chosen middle option; not a hard archive).
- **Storage**: a manifest field (`retired: true` in `.collection.yaml` or the
project registry), committed via bot → mirrored to a `collections.retired`
column, keeping git the source of truth (consistent with §22's
registry-is-truth stance). See gap G-2 for the manifest-vs-DB decision detail.
---
## 4. Data model & API deltas
### 4.1 SPEC (binding) amendments — docs-first (§4.1 handbook)
| Section | Change |
| --- | --- |
| §22.5 | Gated read gate: signed-in non-member on a direct URL → request-access (403 + name) instead of 404; anonymous → 404/login. (§3.8) |
| §22 (new sub-section) | The scoped admin IA: three admin scopes, routing, and the per-tier surface ownership rule. (§3.13.4) |
| §22 collection model | New `retired` collection state (§3.13); explicit collection `path` (§3.11). |
### 4.2 New / changed endpoints
| Method + path | Gate | Purpose |
| --- | --- | --- |
| `GET /api/projects/:pid/collections/:cid/graduation` | collection superuser | scoped graduation queue (§3.6) |
| `GET /api/projects/:pid/collections/:cid/retired` | collection superuser | scoped retired list (§3.6) |
| `POST /api/projects/:pid/collections/:cid/rfcs/:slug/unretire` | collection superuser | collection-scoped un-retire (replaces slug-only, §3.6/§4.4) |
| `PUT /api/projects/:pid` (project settings) | project superuser | name, visibility — git-writing (projects.yaml) |
| `PUT /api/projects/:pid/collections/:cid` (collection settings) | collection superuser | name, visibility, initial_state, path — git-writing (.collection.yaml) |
| `PUT /api/projects/:pid/collections/:cid/fields` | collection superuser | D8 schema editor (.collection.yaml `fields:`) |
| `POST /api/projects/:pid/collections/:cid/retire` / `.../unretire-collection` | collection superuser | retire / un-retire collection (§3.13) |
Note: **no project/collection *settings* write path exists today** — only
create + read (confirmed: no `PUT`/`PATCH` for project/collection metadata). The
settings backend (a git-writing `PUT` per scope) is net-new and is the largest
backend lift here (gap G-1).
### 4.3 New columns / state
- `collections.path TEXT` (default `<collection_id>`) — explicit storage path.
- `collections.retired` (state/flag) — mirrored from manifest.
- Both populated by the registry mirror; **git remains source of truth**.
### 4.4 Closing the slug-ambiguity bug
The current `POST /api/rfcs/:slug/unretire` keys by slug alone — ambiguous when
a slug exists in two collections (the same defect class as the known slug-keyed
`resolve_models_for_rfc`). Re-homing un-retire under
`/projects/:pid/collections/:cid/` resolves it. Audit other slug-only entry
routes for the same issue while in this area (gap G-5).
### 4.5 Prompt collection discovery on content-repo push (decision D-DISCOVER)
A collection is *discovered* — registered in the `collections` table — only by
`refresh_registry` (→ `_mirror_named_collections`, which scans each content repo
for `.collection.yaml` subdirs, `registry.py:305`). The **create-collection API
already calls `refresh_registry`** (`api_collections.py:176`), so collections
created in-app appear immediately. But the **content-repo Gitea webhook does
not**: on a content-repo push it calls only `refresh_meta_repo` /
`refresh_meta_branches` / `refresh_meta_pulls` (`webhooks.py:97100`), never
`refresh_registry`. So a `.collection.yaml` change made **directly in git** — a
new collection, or (in this design) a manifest edit that flips `visibility`,
`retired`, or `path` — is invisible until the next 300 s reconciler sweep
(`cache.Reconciler`, `cache.py:765/789`).
This was observed first-hand seeding this very spec's review collection (a
git-native collection took a full sweep to appear, and the visibility flip took
another). The fix: **the content-repo webhook also triggers `refresh_registry`**
when the push touches a `.collection.yaml` (or unconditionally — the refresh is
idempotent and cheap), bringing the git-native path to parity with the
create-collection API. This makes every manifest-backed admin write in this spec
(visibility, retire, path, fields) take effect promptly rather than on the sweep.
The 300 s reconciler stays as the backstop; this is the fast path, not a
replacement.
---
## 5. Decisions
| # | Decision | Choice | Date |
| --- | --- | --- | --- |
| D-PRIVATE | How "Private" maps to §22.5 | `gated` + request-access page (refine gated from 404 → request-access for signed-in non-members) | 2026-06-08 |
| D-SLUG | Slug editing scope | Create-time config only; no post-create rename | 2026-06-08 |
| D-STORE | Collection storage config | Path within the project's content_repo + auto-create; no different-repo override | 2026-06-08 |
| D-RETIRE | Collection retire model | Distinct `retired` state — hidden, read-only, reversible (mirrors §13.7) | 2026-06-08 |
| D-IA | Admin IA shape | Per-scope routes + scope-switcher + gear (Approach C) | 2026-06-08 |
| D-GRAD | Graduation/retired home | Canonical at collection scope; read-only rollup at project | 2026-06-08 |
| D-AUDIT | Audit/permission-events scope | Left deployment-wide for now (future scoping) | 2026-06-08 |
| D-DISCOVER | Prompt collection discovery | Content-repo webhook also triggers `refresh_registry` on a `.collection.yaml` change (parity with the create-collection API); 300 s reconciler stays as backstop | 2026-06-08 |
---
## 6. Out of scope / deferred
- Post-create **slug rename** (projects & collections) with redirects + cache re-key.
- **Moving** an existing collection's files to a new path (path is settable; the move is deferred).
- Collection in a **different repo** than its project content_repo.
- **Per-scope audit log / permission events** (stay deployment-wide).
- `ref` / `multi-enum` field types in the schema editor (still deferred per the metadata design).
- **Eliminating the collection `type` field.** The metadata work already removed
type-*driven schemas* (D9/D11); fully retiring `type` (today immutable and
terminology-only per §22.4a — it selects the entry noun) is a separate §22.4a
change, not an admin-IA concern. **This spec keeps every admin surface
type-agnostic** — `type` is never presented as a managed setting (see G-11).
---
## 7. Delivery plan (proposed slices)
Each slice is its own writing-plans → executing-plans coding session; plans
authored just-in-time. **S0 is docs-first** (handbook §4.1): amend the binding
SPEC before code.
| Slice | Scope | Delivers |
| --- | --- | --- |
| **S0** | SPEC amendments (§4.1): §22.5 gated→request-access; admin-IA section; `retired` collection state; collection `path` | Binding contract updated |
| **S1** | Scoped admin **routing shell + reachability** (routes, gear, scope switcher), re-home graduation/retired to collection-scoped endpoints + project rollup; slim Site admin; **content-repo webhook → `refresh_registry`** (§4.5) | DEF-1/2/3/4 navigational fix; US-15 |
| **S2** | **Settings write paths** (git-writing `PUT` for project & collection: name, visibility, initial_state) + Settings panels | US-8, US-12 |
| **S3** | **Private + request-access** (gated behavior change, request-access page) + private cascade default | US-13, US-14 |
| **S4** | **Collection storage path** config + auto-provision | US-9, US-10 |
| **S5** | **Metadata field-schema editor** (D8) | US-6 |
| **S6** | **Collection retire** state | US-11 |
| **S8** | **LLM authoring-context config surfaces** (project & collection Context panels; git-persisted block + source list; read-time composition + preview) — §9 | new (LLM-context request) |
(US-1/2/3/4/5/7 are largely satisfied by S1's surfacing of existing create
endpoints + inheritance; create-time slug is already collected.)
---
## 8. Risks, gaps & areas to dig into
*The analysis requested at spec time — what this design leans on that isn't yet
proven, and what a planning session must resolve before coding.*
| # | Gap / risk | Why it matters | Proposed resolution / where to dig |
| --- | --- | --- | --- |
| G-1 | **No settings write path exists** — only create + read. A coherent git-writing `PUT` per scope (project & collection) is net-new and is the bulk of the backend lift | S2 underpins S3S6; if the git-write+mirror loop for settings is wrong, everything downstream wobbles | Dig: design one `PUT` that reads the manifest, merges changed keys, commits via bot, refreshes registry — mirror the create flow (`api_collections.py:119`, `bot.create_collection`). Confirm partial-update semantics & concurrent-edit safety |
| G-2 | **Retire storage: manifest field vs DB flag** | Git-is-truth says manifest; but a DB-only flag is simpler and avoids a commit per retire | Recommend manifest field for consistency with §22; confirm the registry can carry a per-collection boolean and that retired collections survive a mirror rebuild |
| G-3 | **Destructive schema edits orphan values** | Removing a field / narrowing an enum can strand sidecar values | Editor warns; keep-but-ignore orphans (reuse `metadata_malformed`); offer `metadata.migrate_collection`. Decide whether to *block* removal of an in-use field or just warn |
| G-4 | **Path decoupled from slug** — registry today maps collection by conventional `<collection_id>/` subfolder | An explicit `path` must thread through manifest → registry → all path-resolving reads/writes; getting it half-done risks entries written to the wrong place | Audit every place that derives a collection's repo path; add `path` to the registry schema; default to `<collection_id>` for back-compat. Verify against `registry.py` parse/upsert |
| G-5 | **Slug-only entry routes are collection-ambiguous** | `unretire` (and possibly retire / others) key by slug alone (same class as `resolve_models_for_rfc`) | Re-home under `/projects/:pid/collections/:cid/`; grep for other `…/rfcs/:slug…` routes lacking collection scope and fix in the same slice |
| G-6 | **Inherited owners aren't membership rows** | A global/project Owner won't appear in a collection's Members list, which could read as "no owners" | Members panels manage *explicit* grants at that scope; add a read-only "inherited owners (via project/global)" note computed from `effective_role_at_scope`. Decide display, don't change resolution |
| G-7 | **Existence leak on gated request-access** | Revealing a private resource's name to any signed-in URL-holder is a deliberate tradeoff | Accept for the URL-holder (they have the link); never reveal to anonymous. Confirm no deployment needs a strict-404 mode; if so, make it a per-resource toggle (future) |
| G-8 | **Scope-switcher data shape** | Listing accessible-incl-private projects + per-project collections could be N calls | Lazy-load collections when a project row expands, reusing existing endpoints; only build a "my scopes" aggregate endpoint if the N calls prove heavy |
| G-9 | **"Assign owners" at collection create (US-2)** | Create returns the collection; owner assignment is a second `POST …/members` call | Two-step (create → manage members) is acceptable for v1; inline owner-assign at create is a nicety. Decide in S1/S2 |
| G-10 | **Graduation rollup reuses the old global endpoint** | Re-gating `/api/admin/graduation-queue` to project superuser + project filter must not regress the deployment-wide view if any caller still depends on it | Verify no remaining caller expects the unfiltered global behavior before re-gating |
| G-11 | **`type` retained but slated for elimination** | §22.4a keeps `type` immutable + terminology-only; the operator's stated direction is "all collections are documents + metadata." Admin surfaces must not present `type` as a managed setting or they'll contradict that direction | Admin IA stays type-agnostic; track full `type` elimination as its own §22.4a design (out of scope here) |
| G-12 | **LLM authoring-context provisioning** (new request) | A per-scope context block + context-source repos/refs for the in-app LLM raises composition, permission-leak, retrieval-architecture, and vendor-coupling questions (see §9) | Split: admin IA owns the *config surfaces*; a separate spec owns the *consumption/retrieval architecture* (§9) |
### Self-review against the solution-design requirements
- **Placeholders:** none — §3.7 is an intentional reserved spacer, all decisions resolved.
- **Internal consistency:** the per-tier "data it mutates" rule (§3.1) is applied consistently in the re-homing (§3.6) and the surface inventory (§3.4); gates (§3.5) all map to existing `auth.py` primitives.
- **Scope:** broad but coherently sliceable (§7); S2 (settings write path) is the load-bearing prerequisite and is called out as such (G-1).
- **Ambiguity:** the four decisions that had no clear default are pinned in §5; the remaining unknowns are *implementation* unknowns, captured as G-1..G-12 for the planning sessions rather than left implicit.
---
## 9. LLM authoring-context configuration (new)
*Added this session. Decision: the **configuration surfaces** are specced here
(they are genuinely admin IA); how the in-app LLM **consumes** the sources —
retrieval vs RAG, the permission model, token/freshness — is a **separate
design** (§9.5, gap G-12).*
### 9.1 Two distinct concepts (do not conflate)
| Concept | What it is | Scope | Storage |
| --- | --- | --- | --- |
| **Instruction block** | free-form markdown guidance — what this corpus is, how to collaborate on it | project & collection | git, **once per scope** |
| **Context-source list** | ordered list of sources the LLM may draw on: `repo`, `repo+path`, or a **collection-ref** | project & collection | git (scope config) |
Instructions (KB of guidance) and retrieval data (MB of code) have different
sizes, lifecycles, and trust levels — they are separate config, not one field.
### 9.2 Composition — store once, compose at read time (decision)
Project and collection blocks are stored **separately and once each**; the app
**composes** them when building the LLM prompt for an entry:
```
effective authoring context = project.instruction_block ∘ collection.instruction_block
effective sources = project.sources collection.sources
```
No project text is ever copied into a collection file. Updating the project
block changes every collection's effective context with **no file rewrites** —
the opposite of the rejected concatenate-into-files model, which drifts and
clobbers hand-edits. This mirrors the §22.7 downward role composition exactly.
**Storage:** each block is a scope-config value — a referenced `CONTEXT.md` at
the project root / collection path (neutral filename, not vendor-locked),
recorded in `.collection.yaml` / the project registry and mirrored to the DB. A
physical `CLAUDE.md` for real coding sessions, if wanted, is **generated from**
the composition or uses `@import` to reference the project file — never an
embedded copy.
### 9.3 Admin surfaces
- **Project admin → Context** (new panel): edit the project instruction block;
manage project-level sources. Gated `is_project_superuser`.
- **Collection admin → Context** (new panel): edit the collection block; manage
collection sources; **preview the composed result** (project ∘ collection) so
the owner sees exactly what the LLM will receive. Gated `is_collection_superuser`.
### 9.4 Context-source model — unify with `ref`
A source is `repo` (whole repo), `repo+path` (subtree), or a **collection-ref**
(another collection in the deployment). The collection-ref case is the **same
primitive as the deferred `ref` field type** (metadata design D10) — build one
cross-collection reference mechanism, not two.
### 9.5 Deferred to the consumption design (NOT here)
Explicitly out of scope for admin IA; belongs to the separate consumption spec:
- **Retrieval architecture** — tool/file-fetch vs embeddings/RAG (a whole repo
cannot be prompt-stuffed; this drives cost, freshness, infra).
- **Permission model / exfiltration guard** — context-repo reads must be scoped
to what the asking user may already see, or each source flagged explicitly
shareable; otherwise the LLM is a confused-deputy exfiltration channel. **Hard
prerequisite before consumption is enabled.**
- **Token budget, freshness, pinning** (latest vs a pinned ref).
- Tracing how the **current in-app chat** injects context today (it has a system
prompt per `CLAUDE.md`; its retrieval model is untraced).
### 9.6 Delivery
New slice **S8 — context config surfaces**: project & collection Context panels,
git-persisted blocks + source lists, read-time composition + preview. Independent
of the consumption design and ships value alone — owners can author context, and
the composed block can be surfaced/copied manually until consumption is built.