5d0cadda3b
regenerate-readme-index / noop (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
576 lines
37 KiB
Markdown
576 lines
37 KiB
Markdown
---
|
||
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.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 `<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: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 `<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.
|