--- 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//admin` or `/p//c//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//admin │ collection ▶ Collection admin /p//c//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//admin/*` | **new** `ProjectAdmin` (inside `ProjectLayout`) | `is_project_superuser(viewer, pid)` | | `/p//c//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 (`` / 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//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//c//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 (`/`); 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 `/`. - "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.1–3.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 ``) — 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:97–100`), 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 S3–S6; 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 `/` 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 `` 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.