Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fbaa975b5c | |||
| ba37da927a | |||
| 9c8035bdbd | |||
| fd123da6a3 | |||
| 96e2214213 | |||
| 83eafe72ee | |||
| dd9ceff69e | |||
| 52f465b4dd | |||
| 2fc7029bd9 | |||
| 9e1b7ce34f | |||
| cbaba76345 | |||
| 620926b834 | |||
| 4ac3955056 | |||
| 7886840362 | |||
| 8eee907893 | |||
| a2b55f94ce | |||
| edbf68909a | |||
| 282706d7ef | |||
| eaf69cd05c | |||
| 0d2fdfacf2 | |||
| abd17a6cc8 | |||
| 46c957cff5 | |||
| d687a65470 | |||
| aee9b582e5 | |||
| 734290f344 | |||
| 49981e2d6e | |||
| 7ece6d348b | |||
| 6bb6d654fa | |||
| 276a625997 | |||
| ae3afe2f48 | |||
| ae083bfcaa | |||
| bcce40d2cb | |||
| 7b269e11c4 | |||
| 27061c30b0 | |||
| 14ea3c0cce | |||
| 644bf35d89 | |||
| 3636fa5afd | |||
| 1bcf8aa77e | |||
| e336e31812 | |||
| 98c276a662 | |||
| f05ee59763 | |||
| b1acc2382d | |||
| 5cb5f4a4a2 | |||
| 36cb6187eb | |||
| 677c5eb72f | |||
| 077563ea47 | |||
| dc5345cef4 | |||
| ee74a39b62 | |||
| 2f507e5721 | |||
| 9785782532 | |||
| 43a002c6aa | |||
| 8ce3e5792d | |||
| 1be4a2edbf | |||
| 561cd73760 | |||
| 3c910e89ab | |||
| b7e23a01f8 | |||
| 281dd29e62 | |||
| 1c17fecea3 | |||
| fcc3c84d76 | |||
| e86fc65643 | |||
| 014015014b | |||
| b392fa923c | |||
| 839404da0c | |||
| 79a27a946b | |||
| 26f3680197 | |||
| b0737380cd | |||
| 33212c71e4 | |||
| 2696e64ff5 | |||
| ff54632657 | |||
| 93cf506059 | |||
| c9fd1c535e | |||
| e6bd69f132 |
@@ -26,3 +26,4 @@ data/
|
||||
|
||||
# Claude Code (per-machine settings only; shared config under .claude/ is committed)
|
||||
.claude/settings.local.json
|
||||
.superpowers/
|
||||
|
||||
+585
@@ -23,6 +23,591 @@ skip versions are the composition of each intervening adjacent
|
||||
release's steps in order — no A-to-B path is pre-computed beyond
|
||||
that.
|
||||
|
||||
## 0.52.1 — 2026-06-08
|
||||
|
||||
**Patch — collection-scoped entry-detail fetch fix (no operator action
|
||||
required).**
|
||||
|
||||
Caught by the §9 deployed-environment E2E harness (0.52.0) running against
|
||||
a PPE host whose content is cleanly isolated per collection:
|
||||
|
||||
- **Entry detail in a named collection 404'd ("Error: Not found") and its
|
||||
metadata panel never rendered.** `RFCView` computed the collection id
|
||||
from the route but called `getRFC(pid, slug)` without it, so an entry was
|
||||
always fetched via the project's *default*-collection route
|
||||
(`/api/projects/<pid>/rfcs/<slug>`). For an entry that lives only in a
|
||||
named collection that route 404s. The bug was latent since the
|
||||
multi-collection work — local/Tier-1 stacks masked it because the same
|
||||
slug was also reachable through the default collection; a deployment with
|
||||
per-collection-isolated content surfaces it. Fixed: all three `getRFC`
|
||||
call sites in `RFCView` now pass the collection id (and the load effect
|
||||
re-runs on collection change).
|
||||
|
||||
Test-only (not in the deployed artifact): the deployed-env E2E harness now
|
||||
pre-records cookie consent via `addInitScript` so the bottom-fixed consent
|
||||
banner can't intercept catalog row-select clicks on the slower deployed
|
||||
edge, and `testing/seed-ppe.sh` fails loudly on any non-2xx Gitea response
|
||||
(a swallowed 403 had let a missing-repo seed reach the deploy as a 502).
|
||||
|
||||
## 0.52.0 — 2026-06-07
|
||||
|
||||
**Minor — deployed-environment E2E harness (new opt-in test-auth surface;
|
||||
no operator action required for existing deployments).**
|
||||
|
||||
The §9 pipeline's middle stage — running the Playwright E2E suite against
|
||||
a *deployed* pre-prod host — was unreachable because the suite was bound
|
||||
to local-only scaffolding (a docker-seeded faceted collection, a
|
||||
SQLite-injected owner, and Mailpit for the OTC code). This release makes
|
||||
the same suite run green against a deployed host.
|
||||
|
||||
- **New gated test-auth endpoint, `POST /auth/test/login`.** Mints an
|
||||
authenticated **owner** session for a single pre-configured identity, to
|
||||
let the E2E suite sign in without a mail sink. It is **fail-closed**:
|
||||
returns `404` unless **both** `E2E_TEST_AUTH_SECRET` and
|
||||
`E2E_TEST_AUTH_EMAIL` are set (so it is inert in production, which sets
|
||||
neither); requires the secret in the `X-Test-Auth-Secret` header
|
||||
(constant-time compare; wrong/absent → `404`); only mints the one
|
||||
configured email (any other → `403`); and logs a `WARNING` at startup
|
||||
when enabled. **Leave both env vars unset on production.**
|
||||
- **E2E harness parameterized for deployed runs.** `e2e/lib/auth.js`
|
||||
branches on `E2E_TEST_AUTH_SECRET` (deployed test-login vs. the local
|
||||
Mailpit OTC path); `OWNER_EMAIL` reads `E2E_OWNER_EMAIL`. `BASE_URL` was
|
||||
already honored. The local Tier-1 path is unchanged.
|
||||
- **`testing/seed-ppe.sh`** seeds a dedicated, prod-untouching PPE
|
||||
registry + content repo (faceted `bdd` collection) so the deployed
|
||||
fixtures never leak onto real content.
|
||||
|
||||
Design note: `docs/design/2026-06-07-deployed-env-e2e-harness.md`.
|
||||
|
||||
## 0.51.1 — 2026-06-07
|
||||
|
||||
**Patch — two §22 correctness fixes (no operator action required).**
|
||||
|
||||
- **Faceted catalog / bulk bar now scope to the named collection in the URL.**
|
||||
`useCollectionId` read the route param, but the catalog renders above the
|
||||
`c/:collectionId` route, so it always fell back to the default collection —
|
||||
the faceted filter (§22.4a SLICE-3) and bulk action bar (SLICE-5) never
|
||||
scoped to a named, `fields:`-bearing collection in the deployed app. It now
|
||||
resolves the `/c/<id>/` segment from the path. Caught by a new end-to-end
|
||||
Playwright suite exercising the metadata UI against a real browser + Gitea.
|
||||
|
||||
- **Heal the migration-029 vs registry-mirror collection-id divergence.** On a
|
||||
deployment that already held ≥2 projects when migration 029 ran, the default
|
||||
project's collection was seeded as the project id while the mirror expects
|
||||
`'default'`, so the mirror inserted a duplicate empty collection. A new
|
||||
startup reconciler (`projects.reconcile_default_collection_id`, the
|
||||
collection-grain twin of the §22.13 re-stamp) renames the divergent
|
||||
collection to `'default'` before the mirror runs. Idempotent; a no-op on
|
||||
fresh / single-project / already-aligned deployments (incl. the default
|
||||
`document` deployment).
|
||||
|
||||
Also: the per-IP auth rate-limiter budgets are now env-overridable
|
||||
(`RATELIMIT_OTC_REQUEST_MAX` / `_VERIFY_MAX` / `_CHECK_MAX`) for test/PPE stacks
|
||||
that drive auth from one IP; production keeps the secure defaults. The Tier-1
|
||||
test harness is brought current with the §22 three-tier app.
|
||||
|
||||
## 0.51.0 — 2026-06-07
|
||||
|
||||
**Minor — bulk tag/untag metadata edit (§22.4a SLICE-5).**
|
||||
An authorized user can now apply one metadata field change to many catalog
|
||||
entries at once. A new endpoint
|
||||
`POST /api/projects/{id}/collections/{cid}/meta/bulk` takes
|
||||
`{slugs, op: set|add|remove, field, value}`, validates each entry against the
|
||||
collection's `fields:` schema at the write boundary (INV-4), writes the passing
|
||||
entries' `<slug>.meta.yaml` **sidecars** in a **single commit** (D7: bulk = one
|
||||
commit, reusing the SLICE-4 sidecar write-through), re-ingests, and returns
|
||||
`{applied, rejected}` so partial failures (missing entry, invalid value) are
|
||||
reported without sinking the batch. `set` works for any field; `add`/`remove`
|
||||
operate on a `tags`-type field. In the faceted catalog, contributors now get a
|
||||
per-row selection checkbox and a sticky bulk action bar (one "Set ▾" control per
|
||||
enum field, add/remove-tag for tags fields); a successful apply toasts the
|
||||
applied/skipped counts and refreshes the list. This completes PUC-2 of the
|
||||
[Configurable Collection Metadata](./docs/design/2026-06-06-configurable-collection-metadata.md)
|
||||
design (§5.3, §6.4/§6.5).
|
||||
|
||||
No deployment action required. The endpoint and the bulk bar are additive and
|
||||
opt-in per collection: a collection with no `fields:` block (INV-5 — the
|
||||
default `document` collection) shows no selection UI and the endpoint returns
|
||||
`422`, so an N=1 deployment sees zero change.
|
||||
|
||||
## 0.50.0 — 2026-06-07
|
||||
|
||||
**Minor — single-entry metadata edit + sidecar-aware writes (§22.4a SLICE-4).**
|
||||
An authorized user can now edit one entry's schema-defined metadata directly from
|
||||
its detail view. A new endpoint
|
||||
`POST /api/projects/{id}/collections/{cid}/rfcs/{slug}/meta` takes `{values:{…}}`,
|
||||
validates them against the collection's `fields:` schema at the write boundary
|
||||
(INV-4), writes them to the entry's `<slug>.meta.yaml` **sidecar** with a **direct
|
||||
commit** (D7 — no PR for authorized roles), and re-ingests. A legacy entry is
|
||||
**lazy-migrated** to a clean body-only `.md` + sidecar on its first metadata edit.
|
||||
The detail view renders one control per declared field (enum→select, tags→chips,
|
||||
text→input), read-only without contribute access; `GET` on an entry now returns
|
||||
its `meta` mapping and a `can_edit_meta` capability. This completes PUC-1 of the
|
||||
[Configurable Collection Metadata](./docs/design/2026-06-06-configurable-collection-metadata.md)
|
||||
design (§7.2); bulk edit (SLICE-5) follows.
|
||||
|
||||
Carried from SLICE-1: **every entry write path is now sidecar-aware.** Graduation,
|
||||
ownership claim, retire/un-retire, mark-reviewed, the body-edit accept/flush
|
||||
wrappers, and the PR-replay wrappers all dual-read an entry (so a migrated
|
||||
body-only `.md` no longer crashes `entry.parse`) and write metadata changes to the
|
||||
sidecar, keeping the `.md` body pure (INV-2) and never re-growing frontmatter; a
|
||||
legacy entry lazy-migrates on its first metadata-bearing write. With the write
|
||||
paths safe, the **Owner-gated collection-migration endpoint**
|
||||
`POST /api/projects/{id}/collections/{cid}/migrate` now ships (PUC-5): it converts
|
||||
a collection's legacy-frontmatter entries to clean body-only `.md` + sidecars in
|
||||
one commit, idempotently.
|
||||
|
||||
Non-breaking and opt-in (INV-5): a collection with **no `fields:`** exposes no
|
||||
edit panel and the edit endpoint returns 422 ("no editable fields"); the §22.13
|
||||
default `document` collection declares none — so **N=1 deployments see no change**.
|
||||
|
||||
**Upgrade steps**
|
||||
|
||||
- No schema migration. The edit and migrate endpoints are additive; the sidecar
|
||||
storage layer (mig 033) and `meta_json` index (mig 034) shipped in 0.47.0/0.49.0.
|
||||
- A deployment adopts single-entry editing by declaring an `enum`/`tags`/`text`
|
||||
`fields:` block in a collection's `.collection.yaml` (SLICE-2). Contributors+
|
||||
on that collection (§22 Part B / S3 scope roles) may then edit from the detail
|
||||
view; the change is a direct commit to the entry's sidecar.
|
||||
- Operators MAY run `POST …/collections/{cid}/migrate` (collection Owner only) to
|
||||
convert a collection's existing entries to clean body-only docs + sidecars up
|
||||
front. It is idempotent and safe to re-run; dual-read means an un-migrated
|
||||
collection keeps working, and entries lazy-migrate on their first metadata edit
|
||||
regardless. **No data migration is required.**
|
||||
|
||||
## 0.49.0 — 2026-06-07
|
||||
|
||||
**Minor — faceted left-pane filtering (§22.4a SLICE-3).** A collection that
|
||||
declares a metadata field schema (`fields:`, SLICE-2) now gets a **faceted
|
||||
catalog left pane**: one collapsible filter group per `enum`/`tags` field plus
|
||||
state, each with per-value **result counts** and multi-select checkboxes, and a
|
||||
"filter values…" search box on `tags` groups so they stay usable at many values.
|
||||
Filters compose — OR within a field, AND across fields — and a "malformed
|
||||
metadata only" toggle surfaces entries failing their schema (INV-3). This is
|
||||
SLICE-3 of the
|
||||
[Configurable Collection Metadata](./docs/design/2026-06-06-configurable-collection-metadata.md)
|
||||
design (§7.2); the single-entry and bulk metadata-edit UIs (SLICE-4/5) follow.
|
||||
|
||||
The collection-scoped list endpoint
|
||||
(`GET /api/projects/{id}/collections/{cid}/rfcs`, and the project-scoped
|
||||
default-collection alias) now honours filter query params
|
||||
(`?priority=P0&tags=checkout&state=active&malformed=true`), returns a
|
||||
`facets: {field → {value → count}}` block with drill-down counts, and includes
|
||||
each entry's metadata `meta` mapping; an unknown filter field is a 400. Migration
|
||||
034 adds the additive `cached_rfcs.meta_json` column that persists per-entry
|
||||
metadata values for the index.
|
||||
|
||||
Non-breaking and opt-in (INV-5): a collection with **no `fields:`** keeps the
|
||||
existing state-chip catalog unchanged, and the §22.13 default `document`
|
||||
collection declares none — so **N=1 deployments see no change**. The unscoped
|
||||
cross-collection `GET /api/rfcs` is untouched.
|
||||
|
||||
**Upgrade steps**
|
||||
|
||||
- Migration 034 (`cached_rfcs.meta_json`) applies automatically on startup
|
||||
(additive, nullable). It is populated lazily as the corpus reconciler /
|
||||
content-repo webhooks re-ingest each collection; until an entry is re-ingested
|
||||
its `meta_json` is NULL and it contributes no facet values. Operators wanting
|
||||
facets populated immediately MAY trigger a corpus refresh (the reconciler sweep
|
||||
on next startup does this). **No data migration is required.**
|
||||
- A deployment opts a collection into faceting by declaring an `enum`/`tags`
|
||||
`fields:` block in its `.collection.yaml` (SLICE-2). A collection with no
|
||||
`fields:` keeps the existing state-chip catalog unchanged.
|
||||
|
||||
## 0.48.0 — 2026-06-07
|
||||
|
||||
**Minor — collection field schema + central validation (§22.4a SLICE-2).** A
|
||||
collection can now declare a small **field schema** in its `.collection.yaml`
|
||||
(`fields:` block), so its entries carry structured, typed metadata — `priority`,
|
||||
`tags`, and any custom fields the deployment defines. The schema is mirrored into
|
||||
the collection record and served on the collection API; a new central validator
|
||||
checks each entry's stored values against it. This is SLICE-2 of the
|
||||
[Configurable Collection Metadata](./docs/design/2026-06-06-configurable-collection-metadata.md)
|
||||
design (§7.2), building on the SLICE-1 sidecars; faceted filtering (SLICE-3) and
|
||||
the edit UIs (SLICE-4/5) follow.
|
||||
|
||||
Non-breaking and opt-in: a collection with **no `fields:`** behaves exactly as
|
||||
before (INV-5), and the §22.13 default `document` collection declares none
|
||||
(**N=1 sees no change**). No DB migration — the normalized schema rides in the
|
||||
existing `collections.config_json` column.
|
||||
|
||||
Added:
|
||||
|
||||
- **`app/metadata_schema.py`** — the one place that knows a collection's field
|
||||
shapes. `parse_fields(raw)` normalizes a `.collection.yaml` `fields:` block
|
||||
**leniently** (INV-3): a bad block or a bad field def (non-mapping, unknown
|
||||
type, `enum` without a non-empty `values:` list) is skipped with a warning,
|
||||
never fatal — a typo in one field can't drop the whole collection from the
|
||||
mirror. `validate(values, fields)` returns advisory `Problem`s (empty =
|
||||
clean). v1 field types: **`enum`** (single value, controlled by a required
|
||||
`values:`), **`tags`** (a list; free-form unless `values:` given), **`text`**
|
||||
(a free string). `ref` / `multi-enum` are deferred (design §2). Keys an entry
|
||||
carries that the schema does not declare ride along untouched, never flagged
|
||||
(INV-7).
|
||||
- **Registry ingest** (`registry.parse_collection_manifest`) reads the `fields:`
|
||||
block into the collection config (→ `config_json`), beside `enabled_models`.
|
||||
- **Collection read + API** (`collections.get_collection`) unpacks the schema
|
||||
and `GET /api/projects/{id}/collections/{cid}` serves it as `fields` (`null`
|
||||
when unset).
|
||||
- **Advisory validation at ingest** — the corpus mirror
|
||||
(`cache._refresh_collection_corpus`) validates each entry against its
|
||||
collection's schema and flags a violation as `metadata_malformed` **without
|
||||
blocking the read** (INV-3), OR-ed onto the SLICE-1 sidecar-syntax check.
|
||||
Write-boundary **enforcement** (a 422 on the metadata-edit endpoints) lands
|
||||
with those endpoints in SLICE-4/5.
|
||||
|
||||
### Upgrade steps (0.47.0 → 0.48.0)
|
||||
|
||||
- **No migration, no operator action.** A deployment opts in per collection by
|
||||
adding a `fields:` block to that collection's `.collection.yaml`; until it
|
||||
does, behavior is byte-for-byte unchanged.
|
||||
- A collection's field schema is edited in git for v1 (in-app schema management
|
||||
is deferred, design D8). After editing `.collection.yaml`, the registry mirror
|
||||
picks the schema up on its next webhook / reconciler sweep.
|
||||
- No config change. No content change is required.
|
||||
|
||||
## 0.47.0 — 2026-06-07
|
||||
|
||||
**Minor — metadata sidecars: storage + dual-read + migration tool (§22.4a
|
||||
SLICE-1).** Entry metadata can now live in a per-entry `<slug>.meta.yaml`
|
||||
**sidecar**, with the `.md` kept as pure prose (INV-2). The corpus mirror reads
|
||||
the sidecar when present and falls back to legacy top-of-document frontmatter
|
||||
otherwise (**dual-read**, INV-6), so existing corpora load byte-identically.
|
||||
This is SLICE-1 of the
|
||||
[Configurable Collection Metadata](./docs/design/2026-06-06-configurable-collection-metadata.md)
|
||||
design (§7.2); the collection `fields:` schema + validation (SLICE-2), faceted
|
||||
filtering (SLICE-3), and the edit UIs (SLICE-4/5) follow.
|
||||
|
||||
Non-breaking and opt-in: a collection with no sidecars behaves exactly as
|
||||
before, and the §22.13 default collection is untouched (**N=1 sees no change**).
|
||||
|
||||
Added:
|
||||
|
||||
- **Dual-read parser** (`app/metadata.py`) — `read_entry(md, sidecar)` yields
|
||||
identical records whether metadata comes from a sidecar or legacy
|
||||
frontmatter (INV-6); a malformed sidecar never hard-fails a read — the entry
|
||||
still loads and is flagged (INV-3). Unknown / forward-compat keys ride along
|
||||
untouched through parse→serialize and the migration (INV-7;
|
||||
`Entry.extra`).
|
||||
- **Frontmatter→sidecar migration tool** (`metadata.migrate_collection`) — a
|
||||
deterministic, idempotent tool that lifts a collection's legacy entries into
|
||||
sidecars + body-only `.md`s in **one commit** (new Gitea `change_files`
|
||||
batch); a fully-migrated collection is a no-op. The **operator trigger** for
|
||||
it (an Owner-gated endpoint) is intentionally **deferred to SLICE-4**: the
|
||||
propose/graduate/mark-reviewed/edit write paths still read `.md` frontmatter
|
||||
directly, so they must become sidecar-aware before a corpus is migrated in
|
||||
production. Until then the tool is shippable groundwork, not yet wired to a
|
||||
production trigger (INV-8: the engine write paths are unchanged this slice).
|
||||
- **Malformed-metadata flag** — migration `033_metadata_malformed.sql` adds
|
||||
`cached_rfcs.metadata_malformed` (additive); the corpus mirror derives it and
|
||||
the catalog + entry-detail APIs surface `metadata_malformed`. A degenerate
|
||||
sidecar (malformed / empty / slug-less) never drops the entry — it loads with
|
||||
its slug backstopped from the filename and is flagged (INV-3).
|
||||
- **INV-7 at graduation** — graduation now carries an entry's unknown /
|
||||
forward-compat frontmatter keys through the rebuild instead of dropping them.
|
||||
|
||||
### Upgrade steps (0.46.2 → 0.47.0)
|
||||
|
||||
- The framework **MUST** apply migration `033_metadata_malformed.sql` — it runs
|
||||
automatically at startup (additive column, no rebuild, default `0`).
|
||||
- **No operator action** otherwise. With no sidecars present (the default after
|
||||
this upgrade) every corpus stays on the legacy frontmatter path, byte-for-byte
|
||||
as before. The frontmatter→sidecar migration is **not** operator-triggerable
|
||||
yet (its endpoint lands in SLICE-4); dual-read makes the storage change
|
||||
invisible until then.
|
||||
- No config change. No content change is required.
|
||||
|
||||
## 0.46.2 — 2026-06-07
|
||||
|
||||
**Patch — `SPEC.md` §22.4a contract amendment: entry metadata is
|
||||
collection-configured, not type-driven (doc-only).** Reframes the binding
|
||||
§22.4a "collection type" contract so a collection's entry **metadata schema**
|
||||
is **collection-configured** — a `fields:` schema declared in
|
||||
`.collection.yaml` plus per-entry `<slug>.meta.yaml` **sidecars** — rather than
|
||||
a frontmatter schema hard-wired to the collection's `type`. Item 3's
|
||||
**type-specific surfaces** (release planning for `specification`;
|
||||
scenario/coverage views for `bdd`) are **deferred** to a future design; the
|
||||
`bdd` coverage capability is recorded there as a future `ref`-field surface
|
||||
rendered as hyperlinks (never fusing corpora across collections). What `type`
|
||||
still selects is the entry-noun terminology (item 2, shipped v0.45.0) and the
|
||||
default `initial_state` / review posture (§22.4b–c).
|
||||
|
||||
This is **SLICE-0** of the
|
||||
[Configurable Collection Metadata](./docs/design/2026-06-06-configurable-collection-metadata.md)
|
||||
design (§7.2) — the contract amendment that unblocks the build slices. It
|
||||
supersedes the [per-type-surfaces](./docs/design/2026-06-06-per-type-surfaces.md)
|
||||
draft (D11; banner already on that doc).
|
||||
|
||||
**No operator action; no schema or behavior change.** Per-type frontmatter
|
||||
validation was never implemented — the engine treats every entry as markdown +
|
||||
frontmatter regardless of type — so amending the contract changes no runtime
|
||||
behavior, no migration, and no deployment config. Sidecar storage, the
|
||||
collection `fields:` schema, validation, and the metadata UI arrive in later
|
||||
slices (SLICE-1+).
|
||||
|
||||
Changed:
|
||||
|
||||
- **`SPEC.md` §22.4a** — reframed: `type` selects terminology + default
|
||||
`initial_state`/review posture; entry metadata is collection-configured
|
||||
(`fields:` + `<slug>.meta.yaml` sidecars); type surfaces deferred. The
|
||||
`document`/`specification`/`bdd` bullets updated accordingly; a metadata
|
||||
amendment blockquote records the supersession.
|
||||
- **`SPEC.md` §2 and §22 forward-pointers** — the two amendment blockquotes
|
||||
that said "entry frontmatter schema is type-dependent" now read
|
||||
"collection-configured, not type-driven (§22.4a, as amended)".
|
||||
|
||||
## 0.46.1 — 2026-06-06
|
||||
|
||||
**Patch — migration 029 hardening for the §22.13 re-stamp aftermath.** Fixes a
|
||||
crash deploying the three-tier series (v0.40.0+) onto a deployment that went
|
||||
through the v0.39.0 `default`→`<id>` project re-stamp: the re-stamp updated
|
||||
`cached_rfcs.project_id` but **not** the entry-satellite tables, leaving rows at
|
||||
the stale `project_id` that migration 029's per-project collection backfill could
|
||||
not map (`NOT NULL constraint failed: cached_branches__new.collection_id`), plus
|
||||
stale rows that duplicate freshly-re-mirrored ones (`UNIQUE` collision) and stale
|
||||
rows whose entry no longer exists. No operator action; **no schema change** — 029
|
||||
gains a repair prologue only.
|
||||
|
||||
Fixed:
|
||||
|
||||
- **Migration 029 repair prologue** — before rekeying, each entry-satellite table
|
||||
(`cached_branches`, `branch_visibility`, `stars`, `watches`, `pr_seen`, …) has
|
||||
its `project_id` re-derived from its entry (`cached_rfcs`, by slug); rows whose
|
||||
entry no longer exists are dropped (stale cache, rebuildable from gitea), and
|
||||
stale rows that duplicate an already-correctly-stamped row are dropped (keeping
|
||||
the fresh copy). A no-op on a clean/fresh deployment (empty or already-
|
||||
consistent satellites), so fresh installs are unaffected — the existing 029
|
||||
test suite passes unchanged, plus a new regression test for the stale/dup/
|
||||
orphan shape.
|
||||
|
||||
No upgrade steps: applying 029 (now repaired) is automatic on deploy; the repair
|
||||
only mutates the rebuildable `cached_*` caches.
|
||||
|
||||
## 0.46.0 — 2026-06-06
|
||||
|
||||
**Minor (non-breaking) — §22 three-tier refactor, slice S6 (remainder):
|
||||
*request-to-join + the cross-collection inbox (§22.8).* S4 shipped the invite
|
||||
half of joining a gated scope (an Owner grants a role directly); this ships the
|
||||
other half — a user who knows a scope exists asks to join it, naming a desired
|
||||
role, and the request is fanned out to that scope's Owners across the subtree
|
||||
(the cross-collection inbox, §22.11), who accept (writing the `memberships` row)
|
||||
or decline. Purely additive: one new table (no rebuild), one new endpoint group,
|
||||
and inbox/affordance UI; no change to existing read or write semantics.**
|
||||
|
||||
See [`docs/design/2026-06-05-three-tier-projects-collections.md`](./docs/design/2026-06-05-three-tier-projects-collections.md)
|
||||
(Part E slice S6) and `SPEC.md` §22.8 / §22.11. This closes the **request-to-join**
|
||||
item flagged open at 0.45.0; the per-type *surfaces* (§22.4a items 1 & 3) remain
|
||||
the last S6 item, still pending a discovery/spec pass.
|
||||
|
||||
Added:
|
||||
|
||||
- **Request-to-join a scope (§22.8)** — `join_requests` (migration 032), a
|
||||
scope-grain analogue of `contribution_requests`: a `(scope_type ∈
|
||||
{project,collection}, scope_id, requester, requested_role, message, status)`
|
||||
row, one-open-per-`(scope, requester)`. New endpoints under
|
||||
`/api/scopes/{scope_type}/{scope_id}/`: `GET join-target`, `POST
|
||||
join-requests`, and the Owner's `POST .../{id}/accept` / `.../{id}/decline`.
|
||||
Accept writes the membership via `memberships.grant` (the §22.8 "accepting
|
||||
writes the membership row"); the request POST does **not** require the scope be
|
||||
readable — that is how one joins a *gated* scope they were told about.
|
||||
- **The cross-collection inbox (§22.11)** — a join request fans one actionable
|
||||
§15 notification to every Owner whose reach covers the scope (collection
|
||||
Owners + project Owners + global Owners + deployment owners/admins), so it
|
||||
surfaces in the one deployment-wide inbox of anyone who can grant it. New
|
||||
event kinds `join_request_on_scope` (owner-facing, Accept/Decline inline) and
|
||||
`join_request_accepted` / `join_request_declined` (requester-facing).
|
||||
- **Frontend** — a "Request to join" affordance in the project collection
|
||||
directory and the per-collection catalog footer (shown when the viewer is
|
||||
signed in, granted, and holds no role reaching the scope — the new
|
||||
`viewer.can_request_join` flag), a `JoinRequestModal`, and the actionable
|
||||
`JoinRequestRow` in the inbox.
|
||||
- **`auth.effective_role_at_scope(user, scope_type, scope_id)`** — the
|
||||
scope-grain twin of `effective_scope_role` (which keys on a collection),
|
||||
folding global → project for a project target; drives the "already a member?"
|
||||
gate and the `can_request_join` flag.
|
||||
|
||||
No upgrade steps: migration 032 is additive (a new table; no rebuild, no FK
|
||||
changes to existing tables), and no env var or config changes are required.
|
||||
|
||||
## 0.45.0 — 2026-06-06
|
||||
|
||||
**Minor (non-breaking) — §22 three-tier refactor, slice S6: *the SPEC merge +
|
||||
per-collection model universe + the type-driven entry noun.* The three-tier
|
||||
model (deployment → project → RFC collection) is now written into the binding
|
||||
`SPEC.md` as a canonical §22, so the spec finally reflects the shipped S1–S5
|
||||
behavior; a collection may narrow its project's model universe; and the entry
|
||||
noun ("RFC" / "Spec" / "Feature") follows the collection's type. Purely additive
|
||||
over S5 — one additive column (no rebuild), docs, and two small features; no
|
||||
change to existing read or write semantics.**
|
||||
|
||||
See [`docs/design/2026-06-05-three-tier-projects-collections.md`](./docs/design/2026-06-05-three-tier-projects-collections.md)
|
||||
(Part E slice S6; Parts A/B/D, merged into `SPEC.md` §22). With S6 the §22
|
||||
three-tier model is realized in the binding contract. **Two S6 items remain
|
||||
open and are carried to a follow-up slice** (they want a discovery/spec pass
|
||||
first, lacking BDD scenarios in Part C): the per-type *surfaces* — per-type
|
||||
frontmatter schemas, the `specification` release-planning data model, the `bdd`
|
||||
scenario/coverage views (§22.4a items 1 & 3, flagged "first proposals" in the
|
||||
design doc) — and **request-to-join + the cross-collection inbox** (§22.8; S4
|
||||
shipped the invite half).
|
||||
|
||||
Added:
|
||||
|
||||
- **§22 merged into `SPEC.md` (the keystone)** — a canonical §22.1–§22.14 writes
|
||||
the three-tier model into the binding spec: the tiers + collection-grain
|
||||
isolation, the registry (`projects.yaml`) + `.collection.yaml` manifests,
|
||||
one-content-repo-per-project, per-collection slug identity, collection
|
||||
`type`/`initial_state`/`unreviewed`, two-tier (narrow-only) visibility, the
|
||||
unified `{owner, contributor}` role vocabulary at `{global, project,
|
||||
collection}`, the four-layer most-permissive union, discovery/joining,
|
||||
runtime branding, `/p/<project>/c/<collection>/` routing, the one inbox, the
|
||||
per-collection model universe, and the default-project+collection migration.
|
||||
The **S3 keystone reinterpretation of §B.1/§B.3** lands here: a plain granted
|
||||
account is a granted *account*, not a global write role; "global RFC
|
||||
Contributor" is an explicit `scope_type='global'` grant; the implicit-public
|
||||
write baseline is grandfathered onto the migration-seeded default collection
|
||||
only. Forward-pointer amendment notes added at §1/§2/§5/§6. The registry +
|
||||
manifest formats are documented for operators in
|
||||
[`docs/DEPLOYMENTS.md`](./docs/DEPLOYMENTS.md).
|
||||
- **Per-collection model universe (§22.12)** — a collection's `.collection.yaml`
|
||||
may carry an `enabled_models` list that narrows its project's universe, which
|
||||
in turn narrows the deployment `ENABLED_MODELS`. Resolution (extending
|
||||
§6.6/§6.7) is `funder ∩ per-entry models ∩ collection ∩ project`, with the
|
||||
operator providers as the ceiling — a collection can only narrow, never widen.
|
||||
An absent list inherits the parent; an empty list opts the collection out of
|
||||
AI. Surfaced as `enabled_models` on `GET /api/projects/:id/collections/:cid`.
|
||||
- **Type-driven entry noun (§22.4a)** — the displayed noun for an entry follows
|
||||
the collection type (`document` → "RFC", `specification` → "Spec", `bdd` →
|
||||
"Feature"), defined once in the framework and read from the API
|
||||
(`entry_noun` on the collection, directory, and project surfaces) rather than
|
||||
hardcoded. The catalog's propose control and the propose modal name entries
|
||||
accordingly.
|
||||
|
||||
Schema:
|
||||
|
||||
- **Migration `031_collection_enabled_models.sql`** — additive
|
||||
`collections.config_json` (paralleling `projects.config_json`), holding the
|
||||
per-collection `enabled_models`. No table rebuild; `NULL` means "inherit the
|
||||
project's universe."
|
||||
|
||||
Upgrade steps (from 0.44.0):
|
||||
|
||||
1. Rebuild and redeploy as usual; the framework **SHALL** apply migration
|
||||
`031_collection_enabled_models.sql` automatically on start (additive column,
|
||||
no data movement, no operator action).
|
||||
2. A deployment **MAY** now add an `enabled_models` list to any
|
||||
`.collection.yaml` to narrow that collection's model universe, and **MAY**
|
||||
create `specification`- or `bdd`-typed collections to get the corresponding
|
||||
entry noun. Both are optional; absent them every collection behaves exactly
|
||||
as before.
|
||||
|
||||
## 0.44.0 — 2026-06-06
|
||||
|
||||
**Minor (non-breaking) — §22 three-tier refactor, slice S5: *in-app
|
||||
create-project + the global directory.* A global Owner can now stand up a new
|
||||
project end-to-end from the UI — the bot provisions a Gitea content repo and
|
||||
commits the project to `projects.yaml`, and the registry mirror picks it up — and
|
||||
the deployment directory shows a role-aware empty state. Purely additive over S4:
|
||||
one new endpoint and UI, no schema migration, and no change to existing read or
|
||||
write semantics. Completes acceptance scenarios `@S5` (C3.1–C3.2: the
|
||||
global-directory empty states).**
|
||||
|
||||
See [`docs/design/2026-06-05-three-tier-projects-collections.md`](./docs/design/2026-06-05-three-tier-projects-collections.md)
|
||||
(Part E slice S5, Part C.3 scenarios C3.1–C3.2). With S5 the role-and-empty-state
|
||||
focus of Parts B/C is complete; type modules, membership lifecycle, hardening,
|
||||
and the SPEC merge remain S6.
|
||||
|
||||
Added:
|
||||
|
||||
- **In-app create-project (§22 S5 / §A.2)** — `POST /api/projects` (global-Owner
|
||||
only) provisions a Gitea content repo under the deployment org (seeding a
|
||||
`README.md` so `main` exists), commits a new project entry to the registry's
|
||||
`projects.yaml`, then re-runs the registry mirror so the `projects` + default
|
||||
`collections` rows flow from the registry (§22.2 keeps the registry the source
|
||||
of truth). The bot remains the only Git writer (§1); the action is logged
|
||||
(`create_project`) for the §6.5 trail. The body takes `project_id` (a slug, not
|
||||
`default`), `name`, `type` (the initial/default collection's), optional
|
||||
`visibility` (defaults to `public`), and an optional `content_repo` name
|
||||
(defaults to `<id>-content`).
|
||||
- **Create-project gate** — `auth.can_create_project`: "+ New project" is a
|
||||
global-Owner action — a deployment owner/admin, or a holder of an explicit
|
||||
`scope_type='global'` Owner grant. A project- or collection-scope grant, or a
|
||||
global RFC Contributor, cannot create projects (that role creates collections,
|
||||
not projects).
|
||||
- **Deployment-directory capability + redirect signals** — `GET /api/deployment`
|
||||
now carries a `viewer` block (`can_create_project`) and
|
||||
`default_project_readable` (whether the N=1 land-in-corpus redirect target is
|
||||
reachable by this viewer). The frontend bounces into the default project only
|
||||
when it is readable; a gated default (C3.2) or an absent default (C3.1, a
|
||||
deployment with no projects) falls through to the directory's empty state
|
||||
instead of a 404.
|
||||
- **Role-aware global directory (§22 C3.1–C3.2)** — a global Owner landing on an
|
||||
empty deployment directory sees a "Create your first project" CTA (opening the
|
||||
create-project modal: id, name, type, visibility, optional content-repo); a
|
||||
non-owner sees "Nothing has been shared with you yet" and no create action. The
|
||||
directory also gains an Owner-only "New project" control when it is non-empty.
|
||||
|
||||
Upgrade steps:
|
||||
|
||||
1. No operator action is required. S5 adds one endpoint and UI only — there is no
|
||||
migration and no change to existing authorization outcomes. A deployment that
|
||||
was declaring projects directly in `projects.yaml` keeps working unchanged;
|
||||
the new UI is an additional, equivalent way to commit the same registry entry.
|
||||
|
||||
## 0.43.0 — 2026-06-06
|
||||
|
||||
**Minor (non-breaking) — §22 three-tier refactor, slice S4: *invitation
|
||||
surfaces + role-aware empty states.* An Owner can now grant scope roles from the
|
||||
UI, and each tier shows a role-appropriate empty state. Purely additive over S3:
|
||||
new endpoints and UI, no schema migration, and no change to existing read or
|
||||
write semantics. Completes acceptance scenarios `@S4` (C2.1–C2.7: invitation
|
||||
reach, bounding, supersession, and the pending-account floor; C3.3–C3.5: the
|
||||
create-first-collection and propose-first empty states).**
|
||||
|
||||
See [`docs/design/2026-06-05-three-tier-projects-collections.md`](./docs/design/2026-06-05-three-tier-projects-collections.md)
|
||||
(Part E slice S4, Part C.2 and C.3). The in-app create-project flow and the
|
||||
global-directory empty states (C3.1–C3.2) remain S5.
|
||||
|
||||
Added:
|
||||
|
||||
- **Scope-role invitation surface (§22 C.2)** — `POST
|
||||
/api/projects/<id>/members` grants `{owner, contributor}` to an existing
|
||||
account (looked up by email) at the project, or at one collection (with
|
||||
`collection_id`). The grant writes a `memberships` row immediately and emits a
|
||||
§15 personal-direct notification (`scope_role_granted`) naming the project and
|
||||
role — a direct grant, not an accept round-trip. `GET` lists the project
|
||||
subtree's grants (project-Owner view); `DELETE
|
||||
/api/projects/<id>/members/<user_id>` (optionally `?collection_id=`) revokes.
|
||||
- **Invitation gates** — `auth.can_invite_at_project` /
|
||||
`can_invite_at_collection`: managing membership is an Owner capability bounded
|
||||
by the inviter's reach. A project Owner grants at the project or any collection
|
||||
within it; a collection Owner grants only at that collection (never the project
|
||||
or globally); an RFC Contributor manages no membership.
|
||||
- **Broader-scope-supersedes (§22 C.2.6)** — granting at a broader scope removes
|
||||
the grantee's narrower rows that the new grant subsumes (same-or-lower role
|
||||
rank within the subtree); a stronger child grant survives a weaker parent grant
|
||||
(no negative override).
|
||||
- **Pending-account floor (§22 C.2.7)** — a grant to a `pending` deployment
|
||||
account is recorded but confers no write until the account is granted at the
|
||||
deployment (the §6 admission floor in `effective_scope_role`).
|
||||
- **Viewer capability flags** — `GET /api/projects/<id>/collections` carries a
|
||||
`viewer` block (`can_create_collection`, `can_invite`, `role`); `GET
|
||||
/api/projects/<id>/collections/<cid>` carries `viewer.can_contribute /
|
||||
can_invite / role`. These drive the role-aware UI without a second round-trip.
|
||||
- **Role-aware empty states (§22 C.3.3–C.3.5)** — a project Owner landing on an
|
||||
empty project sees a "Create your first collection" CTA (opening the
|
||||
create-collection modal — surfacing the S2 endpoint, previously UI-less); a
|
||||
contributor without create rights sees the bare empty directory; a collection
|
||||
contributor landing on an empty collection sees "Propose the first entry"
|
||||
(anonymous readers keep the S2 sign-in prompt). The directory also gains an
|
||||
Owner-only **Members** control opening the invitation modal.
|
||||
|
||||
Upgrade steps:
|
||||
|
||||
1. No operator action is required. S4 adds endpoints and UI only — there is no
|
||||
migration and no change to existing authorization outcomes. A deployment that
|
||||
was managing `memberships` rows directly (the S3 administrative path) keeps
|
||||
working; the new UI is an additional way to write the same rows.
|
||||
|
||||
## 0.42.0 — 2026-06-05
|
||||
|
||||
**Minor (breaking — upgrade steps below) — §22 three-tier refactor, slice S3:
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
.PHONY: tier1-up tier1-down tier1-logs fe-unit e2e e2e-install
|
||||
.PHONY: tier1-up tier1-down tier1-logs fe-unit e2e e2e-install e2e-fresh
|
||||
|
||||
# Two-phase: run the Gitea seed to completion FIRST so it writes the bot token /
|
||||
# OAuth creds into generated/.env.tier1.generated, THEN create the backend/web —
|
||||
# compose snapshots env_file at container-create time, so the backend must be
|
||||
# created after the seed has populated it. The touch seeds an empty placeholder
|
||||
# for compose's up-front env_file existence check on a clean checkout.
|
||||
tier1-up:
|
||||
touch testing/generated/.env.tier1.generated
|
||||
docker compose -f testing/docker-compose.yml up --build -d gitea-seed
|
||||
docker compose -f testing/docker-compose.yml wait gitea-seed
|
||||
docker compose -f testing/docker-compose.yml up --build -d
|
||||
|
||||
tier1-down:
|
||||
touch testing/generated/.env.tier1.generated
|
||||
docker compose -f testing/docker-compose.yml down -v
|
||||
|
||||
tier1-logs:
|
||||
@@ -17,3 +26,8 @@ e2e-install:
|
||||
|
||||
e2e:
|
||||
cd e2e && BASE_URL=$${BASE_URL:-http://localhost:8080} MAILSINK_URL=$${MAILSINK_URL:-http://localhost:8025} npm run e2e
|
||||
|
||||
# Canonical run: the metadata specs mutate the seeded corpus (edit/bulk write
|
||||
# real commits), so they assume a freshly-seeded stack. This brings the stack
|
||||
# down, back up (re-seeds), and runs the suite once — the shape CI uses.
|
||||
e2e-fresh: tier1-down tier1-up e2e
|
||||
|
||||
@@ -65,6 +65,18 @@ in `rfcs/`" is the whole mental model a new deployer needs.
|
||||
> and its `wiggleverse/rfc-0001-human` repo archived (see §13.6). The
|
||||
> decision record is OHM ROADMAP #36.
|
||||
|
||||
> **Three-tier change (v0.45.0 — supersedes the single-corpus topology;
|
||||
> see §22).** A deployment is no longer one corpus in one repo. It has a
|
||||
> **registry** (§22.2) naming N **projects**, each owning **one content
|
||||
> repo** (§22.3) that holds N typed **RFC collections** as subfolders.
|
||||
> "This single repository is its content repository" now reads "each
|
||||
> *project* names one content repository; the deployment's registry lists
|
||||
> them." The bot and app-owned-authorization paragraphs below are
|
||||
> unchanged and now read **org-wide** across every content repo and the
|
||||
> registry repo. The single-corpus deployment is the N=1 case and keeps
|
||||
> running unchanged via a generated default project + default collection
|
||||
> (§22.13). §22 is the binding model.
|
||||
|
||||
All Git operations on the meta repository are performed by a single **bot
|
||||
service account** in Gitea. Real human users do not have meaningful Gitea
|
||||
permissions on the repo itself; their accounts exist for OAuth identity
|
||||
@@ -107,6 +119,20 @@ That's the entirety of the meta repo. App-level permission state, user
|
||||
accounts, chat history, audit logs, and branch visibility grants do **not**
|
||||
live in the meta repo — they live in the app database (see §5).
|
||||
|
||||
> **Three-tier amendment (v0.45.0 — see §22).** Slugs are unique **per
|
||||
> collection** (§22.4): `model/intro` and `specs/intro` coexist. The entry
|
||||
> **metadata schema is collection-configured** (§22.4a, as amended by
|
||||
> v0.46.2) — each collection declares a `fields:` schema in its
|
||||
> `.collection.yaml` and stores per-entry values in a `<slug>.meta.yaml`
|
||||
> sidecar; the fields below are the `document` baseline, **not**
|
||||
> type-driven. The §2.3 `RFC-NNNN` `max+1` allocation is
|
||||
> **removed** (the slug is the identity); pre-change `id` values survive as
|
||||
> frozen legacy labels. New `active`-entry frontmatter: `unreviewed` (bool)
|
||||
> and the `reviewed_at` / `reviewed_by` provenance pair (§22.4c).
|
||||
> Collection configuration (`type`, `visibility`, `initial_state`) lives in
|
||||
> a `.collection.yaml` manifest in the content repo, mirrored like entry
|
||||
> frontmatter (§22.2).
|
||||
|
||||
### 2.1 Entry file format
|
||||
|
||||
```markdown
|
||||
@@ -298,6 +324,18 @@ the natural path is SQLite FTS5 indexed off the reconciler.
|
||||
These are the tables that are app-owned (not cached from Gitea). Names
|
||||
and exact columns are illustrative; the implementing session can adjust.
|
||||
|
||||
> **Three-tier amendment (v0.45.0 — see §22).** Every entry-scoped table
|
||||
> below carries the corpus grain, which is now the **collection**: the
|
||||
> column is **`collection_id`** (not `project_id`), and `cached_rfcs` is
|
||||
> keyed `(collection_id, slug)`. A denormalized `project_id` rides the
|
||||
> high-churn cache/notification rows for filtering. New app-owned tables:
|
||||
> **`projects`** (one content repo, project settings), **`collections`**
|
||||
> (the immutable `type`, `subfolder`, `initial_state`, `visibility`,
|
||||
> mirrored from `.collection.yaml`, §22.2), and **`memberships`**
|
||||
> (`scope_type ∈ {global, project, collection}`, the unified
|
||||
> `{owner, contributor}` roles — §22.6, replacing M2's `project_members`).
|
||||
> `users.role` is the **deployment** admission tier only (§22.7).
|
||||
|
||||
- `users` — `id`, `email`, `display_name`, `gitea_login`, `role` (one
|
||||
of `owner` / `admin` / `contributor`), `muted` (bool — the §6.2
|
||||
app-wide write-mute, distinct from the per-RFC and per-user
|
||||
@@ -435,6 +473,20 @@ merge with no data movement.
|
||||
|
||||
Authorization is owned by the app. Gitea sees only the bot account.
|
||||
|
||||
> **Three-tier amendment (v0.45.0 — see §22.6–§22.7).** The deployment
|
||||
> roles described in this section are the **global** tier of a four-layer
|
||||
> most-permissive union (global → project → collection → per-entry).
|
||||
> Scope roles use one vocabulary — **Owner** and **RFC Contributor** —
|
||||
> attached at `{global, project, collection}` via the `memberships` table
|
||||
> and inheriting downward with no negative override. A plain granted
|
||||
> `contributor` account is **not** an implicit global write role: it can
|
||||
> sign in and read, but writing an explicitly-created collection requires
|
||||
> an explicit `memberships` grant. The one carve-out is the N=1
|
||||
> default-collection baseline, where the pre-multi-project implicit-public
|
||||
> write capability is grandfathered (§22.6 keystone note). The §6.3
|
||||
> per-RFC delegated-authority idea is now **Owner** at project/collection
|
||||
> scope, sitting above per-entry authority in the union.
|
||||
|
||||
Authentication has three paths, in the order a visitor encounters
|
||||
them:
|
||||
|
||||
@@ -845,7 +897,13 @@ a hierarchy on the user that gets in the way of finding by title.
|
||||
- **Filter chip strip** — multi-select, AND-combined. Chips:
|
||||
`State: super-draft | active | withdrawn`, `My RFCs` (I'm an owner
|
||||
or arbiter), `Has open PRs`, `Unclaimed` (super-drafts with empty
|
||||
`owners:`), `Tag: …`.
|
||||
`owners:`), `Tag: …`. A collection that declares a metadata field
|
||||
schema (§22.4a) replaces this chip strip with **faceted filter
|
||||
groups** — one collapsible group per `enum`/`tags` field plus state,
|
||||
each showing per-value result counts and multi-select checkboxes
|
||||
(OR within a field, AND across fields); see the Configurable
|
||||
Collection Metadata design. A collection with no `fields:` schema
|
||||
keeps the chip strip described here unchanged.
|
||||
|
||||
### 7.2 The list rows
|
||||
|
||||
@@ -4912,3 +4970,524 @@ existing consenters. The cleanest moment to do this is the next
|
||||
material privacy-policy revision; the conventions in §21.4 hold
|
||||
in the interim.
|
||||
|
||||
---
|
||||
|
||||
## 22. Three tiers: deployment → project → RFC collection
|
||||
|
||||
A **deployment** hosts one or more **projects**; each project owns one
|
||||
content repository and holds one or more **RFC collections**; each
|
||||
collection is a typed corpus of **entries**. This is the three-tier model
|
||||
that supersedes the original single-corpus framing of §§1–21 and the
|
||||
two-tier (deployment → project) draft that preceded it.
|
||||
|
||||
```
|
||||
deployment (= "global" in the UI) one Gitea org, one bot, one account
|
||||
│ system, one inbox, one running process;
|
||||
│ the surface a visitor first lands on.
|
||||
└─ project a named grouping + project settings;
|
||||
│ owns exactly ONE content repo. No type.
|
||||
└─ RFC collection a typed corpus: type, slug namespace,
|
||||
│ catalog, philosophy, initial_state,
|
||||
│ unreviewed flag, members.
|
||||
└─ entry an RFC / spec / feature, identified by
|
||||
its slug within the collection.
|
||||
```
|
||||
|
||||
Everything §§1–21 describe about *a corpus* is now *an RFC collection*.
|
||||
Everything they describe about *a deployment* that is not corpus-specific —
|
||||
accounts, the §6 admission gate, the §15 inbox, the §1 bot — stays at the
|
||||
deployment level and is shared. A grouping layer, the **project**, sits
|
||||
between: it owns the content repo and project-wide settings, and groups the
|
||||
collections beneath it. The numbered sections that assume a single corpus are
|
||||
amended in §22.14; **§22 is the binding model they defer to.**
|
||||
|
||||
> **Three-tier change (v0.40.0 → v0.45.0 — supersedes the single-corpus and
|
||||
> the two-tier models).** §1 originally said "this single repository is its
|
||||
> content repository," and an earlier draft of this section said "a deployment
|
||||
> hosts N projects, each a corpus." The current model is three tiers: a
|
||||
> deployment has a **registry** (§22.2) naming N **projects**, each project
|
||||
> owns **one content repo** (§22.3) holding N **RFC collections** as typed
|
||||
> subfolders (§22.2). The single-corpus deployment is the **N=1 case** and
|
||||
> continues to run after migration via a generated default project carrying a
|
||||
> generated default collection (§22.13); no deployment is forced to adopt more
|
||||
> than one of either. Where earlier sections say "the meta repo," "the corpus,"
|
||||
> or "the project," read "the collection's content repo" and "the collection's
|
||||
> corpus." The slices that delivered this are S1–S6 (the design record is
|
||||
> `docs/design/2026-06-05-three-tier-projects-collections.md`).
|
||||
|
||||
### 22.1 The tiers and isolation
|
||||
|
||||
One deployment, N projects (N ≥ 1); one project, N collections (N ≥ 1). A
|
||||
collection belongs to exactly one project; a project to exactly one
|
||||
deployment; neither moves. **Isolation (§22.5) holds at the collection
|
||||
grain:** an RFC, branch, thread, star, or watch belongs to exactly one
|
||||
collection, and no app surface joins across collections except the
|
||||
per-account ones the deployment owns (the §15 inbox, the §6 account roster,
|
||||
sign-in).
|
||||
|
||||
- **Deployment / "global."** The unchanged top tier. Owns accounts, the §6
|
||||
admission gate, the §15 inbox, the §1 bot, and the landing directory. Its
|
||||
management surface is **projects + global settings**.
|
||||
- **Project.** Belongs to one deployment; never moves. Owns one content repo
|
||||
(§22.3) and carries project settings (name, tagline, theme, visibility,
|
||||
model universe). Has **no `type`** of its own. Its management surface is
|
||||
**RFC collections + project settings**.
|
||||
- **RFC collection.** A typed subfolder of its project's content repo (§22.2).
|
||||
Carries everything the original draft pinned on a "project": the immutable
|
||||
`type` (§22.4a), the per-collection slug namespace (§22.4), `initial_state`
|
||||
(§22.4b), the `unreviewed` flag (§22.4c), catalog, philosophy.
|
||||
- **Entry.** Unchanged (§2). Identified by its slug **within its collection**.
|
||||
|
||||
### 22.2 The registry and the collection manifests — git is still truth
|
||||
|
||||
Project and collection configuration is declared in git and mirrored into
|
||||
cache tables (`projects`, `collections`) exactly the way content is mirrored
|
||||
into `cached_rfcs` (§4). There are **two git sources**, both read by the bot:
|
||||
|
||||
1. **The registry repo** declares **projects**. A `projects.yaml` at the root
|
||||
of a dedicated **registry repo** under the deployment's Gitea org lists each
|
||||
project's `id`, `name`, `content_repo`, `visibility`, `theme`, and
|
||||
`enabled_models`. The framework learns the registry repo's location from a
|
||||
required env var (`REGISTRY_REPO`, the successor to `META_REPO`); the repo's
|
||||
*name* is the deployment's choice per the separation-of-concerns rule, and
|
||||
the framework fails loudly at startup if the var is unset. `content_repo`
|
||||
lives on the **project** (one repo per project), not the collection.
|
||||
|
||||
2. **Each project's content repo** declares its **collections** as typed
|
||||
subfolders, each carrying a **`.collection.yaml` manifest** (the
|
||||
collection's `type`, `visibility`, `initial_state`, `name`, and optional
|
||||
`enabled_models`). The registry mirror walks the content repo and reads
|
||||
these manifests, so collection configuration is git-truth and survives a
|
||||
cache rebuild — exactly as entry frontmatter does.
|
||||
|
||||
```yaml
|
||||
# projects.yaml (registry repo root)
|
||||
deployment:
|
||||
name: Wiggleverse # deployment display name (replaces VITE_APP_NAME)
|
||||
tagline: ... # deployment landing deck (§22.10)
|
||||
projects:
|
||||
- id: ohm # url-stable slug, unique within the deployment
|
||||
name: Open Human Model
|
||||
content_repo: ohm-content # ONE repo under the org; collections live inside it
|
||||
visibility: public # gated | public | unlisted (§22.5)
|
||||
theme: { accent: "#5b5bd6" } # optional per-project token overrides (§22.9)
|
||||
enabled_models: [claude, gemini] # optional; falls back to deployment ENABLED_MODELS
|
||||
```
|
||||
|
||||
```yaml
|
||||
# ohm-content/model/.collection.yaml (one per collection subfolder)
|
||||
type: document # document | specification | bdd — immutable (§22.4a)
|
||||
visibility: gated # defaults to the project's, may only narrow (§22.5)
|
||||
initial_state: super-draft # super-draft | active — defaults from type (§22.4b)
|
||||
name: The Model
|
||||
# enabled_models: [claude] # optional; narrows the project's universe (§22.12)
|
||||
```
|
||||
|
||||
```
|
||||
ohm-content/
|
||||
model/
|
||||
.collection.yaml # type: document
|
||||
rfcs/intro.md
|
||||
specs/
|
||||
.collection.yaml # type: specification
|
||||
rfcs/runtime.md
|
||||
features/
|
||||
.collection.yaml # type: bdd
|
||||
rfcs/login.md
|
||||
```
|
||||
|
||||
**Creation is in-app, wrapping a bot commit, at both tiers.** *+ New project*
|
||||
(a global-Owner action, §22.6) has the bot create a Gitea content repo under
|
||||
the org and commit a project entry to `projects.yaml`. *+ New collection* (a
|
||||
project Owner / RFC-Contributor-with-create action) has the bot commit a new
|
||||
subfolder + `.collection.yaml` to the project's content repo. The in-app
|
||||
button is a thin convenience over a git write; nothing becomes app state that
|
||||
git cannot rebuild. `projects` and `collections` cache rows are never written
|
||||
from user actions directly — they flow from the mirror only. **Membership**
|
||||
(§22.6) remains app state, as `rfc_collaborators` always has been — it churns
|
||||
at user speed and is not document state.
|
||||
|
||||
### 22.3 Content repositories — one per project
|
||||
|
||||
Each project names one content repo under the deployment's single Gitea org
|
||||
(convention `<project-id>-content`). Collections are **subfolders** within it
|
||||
(§22.2). The §1 bot service account operates org-wide across every content
|
||||
repo and the registry repo; nothing about the bot, the §6 app-owned
|
||||
authorization, or the "app is the only contribution surface" stance changes.
|
||||
There are no per-project Gitea orgs and no per-project bot accounts.
|
||||
|
||||
### 22.4 The slug namespace is per-collection; the slug is the identity
|
||||
|
||||
An entry's slug (§2) is unique **within its collection**: `model/intro` and
|
||||
`specs/intro` coexist. The fully-qualified identity is `(project, collection,
|
||||
slug)` — there is no type prefix and **no numeric ID**. The §22.4 retirement
|
||||
of `RFC-NNNN` allocation stands: the slug is the identity, and graduation
|
||||
(§13) flips state without allocating a number. The displayed *noun* around a
|
||||
slug ("RFC", "Spec", "Feature") is a presentation concern driven by the
|
||||
collection's `type` (§22.4a), not part of the identity.
|
||||
|
||||
**Legacy numbers.** Entries graduated *before* this change keep their existing
|
||||
`id` (`RFC-NNNN`) in frontmatter as a **frozen, non-identity legacy label** —
|
||||
preserved and shown so external "RFC-0001"-style citations still resolve, but
|
||||
never used for routing or lookup. New entries are never assigned one.
|
||||
|
||||
### 22.4a Collection type
|
||||
|
||||
> **Metadata amendment (v0.46.2 — Configurable Collection Metadata).** Item 1
|
||||
> below originally made the **entry metadata schema type-driven** — a
|
||||
> `document`/`specification`/`bdd` frontmatter schema baked into a per-type
|
||||
> module. That is **superseded**: entry metadata is **collection-configured**,
|
||||
> not type-driven. Each collection declares a `fields:` schema in its
|
||||
> `.collection.yaml`, and per-entry values live in a `<slug>.meta.yaml`
|
||||
> **sidecar** (the `.md` body stays pure prose; a parser reads the sidecar
|
||||
> else legacy top-of-doc frontmatter). See the
|
||||
> [Configurable Collection Metadata](./docs/design/2026-06-06-configurable-collection-metadata.md)
|
||||
> design, which supersedes the
|
||||
> [per-type-surfaces](./docs/design/2026-06-06-per-type-surfaces.md) draft
|
||||
> (D11). Item 3's **type-specific surfaces** (release planning for
|
||||
> `specification`; scenario/coverage views for `bdd`) are **deferred** to a
|
||||
> future design; the `bdd` **coverage** capability is recorded there as a
|
||||
> future `ref`-field surface that maps features to the spec entries they
|
||||
> verify **as hyperlinks**, honoring the §22 rule against fusing corpora
|
||||
> across collections. What `type` still selects is the **terminology** (item 2,
|
||||
> the entry noun, shipped v0.45.0) and the **default `initial_state` / review
|
||||
> posture** (§22.4b–c).
|
||||
>
|
||||
> **Shipped status.** The design lands incrementally: sidecar storage +
|
||||
> dual-read (SLICE-1, v0.47.0), the collection `fields:` schema + central
|
||||
> validation (SLICE-2, v0.48.0), faceted left-pane filtering (SLICE-3, v0.49.0),
|
||||
> and **single-entry metadata edit (SLICE-4, v0.50.0)** — the direct-commit
|
||||
> `POST …/rfcs/{slug}/meta` editor (contributor+, validated at the write
|
||||
> boundary), the schema-driven detail panel, all entry **write paths made
|
||||
> sidecar-aware** (a migrated body-only `.md` never re-grows frontmatter), and
|
||||
> the Owner-gated `POST …/collections/{cid}/migrate` endpoint. **Bulk
|
||||
> tag/untag (SLICE-5, v0.51.0)** completes the design: the
|
||||
> `POST …/collections/{cid}/meta/bulk` endpoint (`{slugs, op: set|add|remove,
|
||||
> field, value}`) applies one field change to many entries' sidecars in a
|
||||
> **single commit** (D7), validated per entry at the write boundary with
|
||||
> partial-rejection reporting (`{applied, rejected}`), plus the catalog's
|
||||
> row multi-select + sticky bulk action bar. See §9.5 for the edit-metadata
|
||||
> write-through this reuses.
|
||||
|
||||
Every collection declares a `type` in its `.collection.yaml` manifest
|
||||
(§22.2), chosen at creation and **immutable**: one of `document`,
|
||||
`specification`, or `bdd`. Type does not change the engine — every type uses
|
||||
the same content repo (§22.3), the same propose→branch→PR→discuss→graduate
|
||||
lifecycle (§§9–13), the same threads, flags, and chat. Type selects:
|
||||
|
||||
1. the **terminology** the chrome uses for an entry (the §8.1 noun, catalog
|
||||
labels) — the entry noun, shipped v0.45.0;
|
||||
2. the **default `initial_state`** a new entry lands in, and its review
|
||||
posture (§22.4b, §22.4c).
|
||||
|
||||
Entry **metadata** is **not** selected by type — it is **collection-configured**
|
||||
(a `.collection.yaml` `fields:` schema + per-entry `<slug>.meta.yaml`
|
||||
sidecars; see the amendment above, the Configurable Collection Metadata
|
||||
design, and the §2 baseline). **Type-specific surfaces** layered on the shared
|
||||
§7 catalog are **deferred** to a future design.
|
||||
|
||||
Type-specific behavior, where it exists, is implemented as a per-type module
|
||||
the framework selects on `collection.type`; the engine itself treats every
|
||||
entry as markdown + a metadata sidecar regardless of type. `type` is an
|
||||
**open set** in shape — a future type is a new module plus a new allowed enum
|
||||
value, no schema rebuild. The type names and their behavior are framework
|
||||
concepts (like role names), not deployment content: a deployment picks which
|
||||
type each collection is, but does not define or rename types.
|
||||
|
||||
- **`document`** — long-form normative prose (OHM: a model of principles and
|
||||
definitions). Metadata is the §2 baseline; no collection-configured `fields:`
|
||||
are required. The §22.13 generated default collection is a `document`
|
||||
collection with no `fields:`, so the N=1 case is unchanged.
|
||||
- **`specification`** — a versioned technical specification (this framework's
|
||||
own `SPEC.md` is the archetype). A deployment that wants spec metadata
|
||||
(`version`, lifecycle `status` of draft/active/superseded, `supersedes`)
|
||||
declares those as collection `fields:`. **Release planning** — grouping
|
||||
entries into versioned releases with a changelog + §20-style upgrade-steps —
|
||||
is a **deferred** type surface.
|
||||
- **`bdd`** — behavior-driven feature specs: each entry states a feature as
|
||||
Given/When/Then scenarios with acceptance criteria. Feature metadata is
|
||||
declared as collection `fields:`. The **scenario/acceptance view** and a
|
||||
**coverage view** (mapping features to the spec entries they verify via a
|
||||
future `ref` field, rendered as hyperlinks — never fusing corpora across
|
||||
collections) are **deferred** type surfaces.
|
||||
|
||||
### 22.4b Initial state of a new entry
|
||||
|
||||
A collection sets the **landing state** a new entry takes when its creating
|
||||
idea-PR merges (§2.4) — the `initial_state` manifest field, one of the §2.4
|
||||
entry-states:
|
||||
|
||||
- **`super-draft`** (default for `document` and `specification`) — a new entry
|
||||
lands as a super-draft and must be explicitly graduated (§13) to reach
|
||||
`active`. This is today's flow.
|
||||
- **`active`** (default for `bdd`) — a new entry lands `active` on the idea-PR
|
||||
merge, with the **`unreviewed` flag set** (§22.4c). The §13 graduate gate is
|
||||
not surfaced — the entry is already active — but because nothing reviewed
|
||||
it, the flag marks it as not-yet-vetted until an owner clears it.
|
||||
|
||||
The default comes from the collection's **type** (§22.4a), but `initial_state`
|
||||
is an independent knob. It changes only the landing state and whether
|
||||
graduation is required; the underlying engine is unchanged. The §22.13 default
|
||||
collection keeps `super-draft`, preserving the N=1 flow.
|
||||
|
||||
### 22.4c The `unreviewed` flag
|
||||
|
||||
An `active` entry carries an **`unreviewed`** boolean, orthogonal to its
|
||||
`state`, recording whether a human gate has vetted it. An entry that reaches
|
||||
`active` by the normal **graduate** path (§13) is never flagged — the graduate
|
||||
action *is* the review. An entry that skips straight to `active` via
|
||||
`initial_state: active` lands `unreviewed = true`. A collection **Owner**
|
||||
(§22.6) clears it with a **mark-reviewed** action (§17), stamping
|
||||
`reviewed_at`/`reviewed_by` for provenance. The flag is git-truth
|
||||
(frontmatter, §2 amendment) and survives a cache rebuild. The §7 catalog gains
|
||||
an **unreviewed filter** — the owner's worklist for the action.
|
||||
|
||||
### 22.5 Visibility applies at both project and collection
|
||||
|
||||
Visibility is `gated` | `public` | `unlisted`, and is carried at **both** the
|
||||
project and the collection tier:
|
||||
|
||||
- **`gated`** — invisible to non-members: not shown in the directory (§22.10),
|
||||
returns 404 to non-members, and reading or writing requires a scope role
|
||||
(§22.6).
|
||||
- **`public`** — any visitor may read under the §6.1 anonymous-read contract;
|
||||
appears in the directory; contributing still requires a grant (subject to
|
||||
the N=1 baseline, §22.6).
|
||||
- **`unlisted`** — readable by anyone with a direct link, but not shown in the
|
||||
directory and not enumerated by the deployment/project listing.
|
||||
|
||||
**A collection defaults to its project's visibility and may only narrow it**
|
||||
(`public` < `unlisted` < `gated`; a collection may be as strict or stricter
|
||||
than its project, never looser). Reading or writing a collection requires
|
||||
passing **both** gates — the stricter of project and collection wins. This is
|
||||
validated at create-collection (422 on a looser setting) and clamped at the
|
||||
registry mirror. Project/collection visibility does not relax the §11
|
||||
per-branch `read_public` controls *within* a collection.
|
||||
|
||||
### 22.6 Roles: one vocabulary, attached at a scope
|
||||
|
||||
There is **one role enum — `{owner, contributor}`** — displayed as **Owner**
|
||||
and **RFC Contributor**. A grant *attaches that role at a scope*: **global**,
|
||||
**project**, or **collection**. "Owner at all levels, RFC Contributor at all
|
||||
levels" is literal — the same two words at every tier.
|
||||
|
||||
| Role | Capabilities within its scope's subtree |
|
||||
|---|---|
|
||||
| **Owner** | Superuser: manage settings and membership; create child projects/collections; act on any entry (merge on behalf, graduate, mark-reviewed, withdraw/reopen, set branch visibility). |
|
||||
| **RFC Contributor** | Propose entries, create branches, open PRs, claim unclaimed super-drafts, participate in discussion. At **project** (or global) scope this additionally includes **creating collections** in that project. (A *collection*-scope grant cannot create sibling collections — creating one is a project-level action.) |
|
||||
|
||||
**Schema.** `users.role` continues to carry the **deployment admission** tier
|
||||
(`owner` / `admin` / `contributor`, the §6 gate). Scope grants live in a single
|
||||
polymorphic **`memberships(scope_type ∈ {global, project, collection},
|
||||
scope_id, user_id, role, granted_by, granted_at)`** table; the role enum is
|
||||
`{owner, contributor}`. The prior `project_members` three-role set
|
||||
(`viewer`/`contributor`/`admin`) collapsed: `admin → owner`, `contributor →
|
||||
contributor`, and `viewer` is deferred (a read grant folded into visibility,
|
||||
not a membership role this pass). When the richer set returns it **re-splits
|
||||
out of** Owner / re-adds a tier; the unified roles are not aliases.
|
||||
|
||||
> **Keystone reinterpretation (v0.42.0, S3 — reconciles the role mapping).**
|
||||
> An earlier draft equated "deployment `contributor`" with "global RFC
|
||||
> Contributor." That contradicted the open-by-default baseline. The binding
|
||||
> reading: a **plain granted account** (`users.role='contributor'`, no
|
||||
> membership row) is a granted *account* — it can sign in and read — **not** a
|
||||
> write-everywhere global role. **"Global RFC Contributor"** is an **explicit
|
||||
> `memberships(scope_type='global')` grant** (the cleo case, §22.6a). The one
|
||||
> carve-out preserving N=1: the pre-multi-project **implicit-public write
|
||||
> baseline is grandfathered onto the migration-seeded `default` collection
|
||||
> only** — a granted `contributor` keeps its historical write capability there
|
||||
> with no membership row. Every **explicitly created** collection (and any
|
||||
> second project) requires an explicit scope grant to write. Deployment
|
||||
> `owner`/`admin` remain superusers everywhere.
|
||||
|
||||
Membership is still gated by the deployment-level
|
||||
`users.permission_state='granted'` (§6): a pending account has no write
|
||||
capability at any scope regardless of its `memberships` rows.
|
||||
|
||||
### 22.6a Role & invitation scenarios
|
||||
|
||||
The behavioral spec for role usage, invitation, and empty-state experiences is
|
||||
the BDD scenario set in
|
||||
`docs/design/2026-06-05-three-tier-projects-collections.md` Part C (C.1 role
|
||||
usage / inheritance / most-permissive union; C.2 invitation — who may invite
|
||||
whom, at which scope; C.3 empty states at each tier). They are written so they
|
||||
can also seed a `bdd`-type collection (the framework dogfooding its own model).
|
||||
Each scenario carries the slice tag (`@S1`–`@S6`) that makes it pass.
|
||||
|
||||
### 22.7 How the four tiers compose
|
||||
|
||||
Effective authority on an entry is the **most permissive** union of four
|
||||
layers, inheriting **downward**, **additive**, with **no negative override**:
|
||||
|
||||
```
|
||||
effective authority on an entry =
|
||||
global role (memberships scope_type='global'; + users.role owner/admin)
|
||||
∪ project role (membership at the entry's project)
|
||||
∪ collection role (membership at the entry's collection)
|
||||
∪ per-entry authority (owners / arbiters / rfc_collaborators — §6.3, §12)
|
||||
then minus §6.2 write-mute and §22.5 visibility (subtractive, as today)
|
||||
```
|
||||
|
||||
- A grant at **global** covers every project and collection in the deployment.
|
||||
- A grant at **project** covers every collection in that project — including
|
||||
collections added later, with no new grant.
|
||||
- A grant at **collection** covers just that collection.
|
||||
- You **cannot** grant at a parent scope and revoke at a child; resolution
|
||||
never subtracts a parent grant.
|
||||
|
||||
**Per-entry authority is a distinct, finer layer — not a synonym.** `owners` /
|
||||
`arbiters` / `rfc_collaborators` apply to *one specific entry* (§6.3, §12);
|
||||
the three named scopes apply to a *subtree*. `arbiter` is narrower than Owner
|
||||
(one entry, not a subtree) and stays distinct. `users.role` now means
|
||||
deployment level only; no schema change demotes an existing owner/admin —
|
||||
their powers read as "superuser in every project and collection."
|
||||
|
||||
### 22.8 Discovery and joining
|
||||
|
||||
Because a gated project or collection is invisible to non-members, joining is
|
||||
by one of:
|
||||
|
||||
- **Invite** — an Owner (at the target scope or any scope above it) grants a
|
||||
user a role directly, writing a `memberships` row and fanning a §15
|
||||
notification. The grant may name any scope at or beneath the inviter's reach;
|
||||
the **broader-scope-supersedes** rule prunes membership rows the new grant
|
||||
subsumes (a project grant removes subsumed collection rows of same-or-lower
|
||||
rank; a global grant removes subsumed project + collection rows; a *stronger*
|
||||
child grant survives).
|
||||
- **Request to join** — a user who knows a scope exists requests membership
|
||||
naming a desired role; the request is recorded and surfaced to the scope's
|
||||
Owners across the subtree (the cross-collection inbox), who accept or decline.
|
||||
Accepting writes the `memberships` row.
|
||||
|
||||
A `public` project/collection needs neither for read; the existing §6 / §12
|
||||
contribute-grant paths cover write.
|
||||
|
||||
### 22.9 Branding is resolved at runtime
|
||||
|
||||
`VITE_APP_NAME` is **deprecated** (§20 amendment): a single build-time name
|
||||
cannot serve N projects. Deployment, project, and collection identity are
|
||||
served at runtime — `GET /api/deployment` (deployment `name`, `tagline`, the
|
||||
visible projects), `GET /api/projects/:id` (the project's settings + visible
|
||||
collections), `GET /api/projects/:id/collections/:cid` (the collection's
|
||||
settings incl. `type`). The frontend reads these instead of
|
||||
`import.meta.env.VITE_APP_NAME`. Three chrome layers result: **deployment
|
||||
chrome** (directory, switcher, shared inbox), **project chrome** (the
|
||||
collection directory, project settings), and **collection chrome** (the §7
|
||||
catalog, the §8 entry view, the §14 philosophy).
|
||||
|
||||
### 22.10 Routing and the landing surfaces
|
||||
|
||||
The canonical route gains a collection segment:
|
||||
|
||||
```
|
||||
/p/<project>/c/<collection>/e/<slug>
|
||||
```
|
||||
|
||||
The `c/` segment keeps collection ids from colliding with reserved
|
||||
project-level segments. Reserved **collection-level** siblings (`proposals`,
|
||||
`philosophy`) sit under `/p/<project>/c/<collection>/…`. The displayed entry
|
||||
noun is the collection type's label (§22.4a), not part of the path.
|
||||
|
||||
- `/` is the **deployment landing**: a directory of the projects the visitor
|
||||
can see (§22.5). Redirects to the sole visible project when there is exactly
|
||||
one (the N=1 case).
|
||||
- `/p/<project>/` is the **project landing**: a directory of the collections
|
||||
the visitor can see. Redirects to its sole visible collection when there is
|
||||
exactly one.
|
||||
|
||||
**Backcompat.** The shipped `/p/<project>/e/<slug>` URLs (v0.35.0)
|
||||
**308-redirect** to `/p/<project>/c/<default>/e/<slug>`, and the
|
||||
pre-multi-project `/rfc/<slug>` / `/proposals/<n>` redirect to their
|
||||
`/p/<default-project>/c/<default-collection>/…` equivalents. Both are handled
|
||||
in the migration (§22.13).
|
||||
|
||||
### 22.11 Notifications span the deployment, one inbox
|
||||
|
||||
Accounts are deployment-wide, so the §15 inbox is one inbox across all the
|
||||
caller's collections. Entry-scoped notification rows carry the entry's
|
||||
`collection_id` (and a denormalized `project_id`) so the inbox filters by
|
||||
collection or project and a user can mute an entire collection. Quiet hours,
|
||||
digest cadence, and email preferences stay per-account at the deployment level
|
||||
(§5, §15). The **cross-collection inbox** (§22.8) surfaces join requests to
|
||||
the Owners of the scope they target, aggregated across the subtree.
|
||||
|
||||
### 22.12 Per-collection model universe
|
||||
|
||||
A collection's `enabled_models` (its `.collection.yaml` manifest, §22.2)
|
||||
narrows its **project's** `enabled_models` (registry, §22.2), which in turn
|
||||
overrides the deployment `ENABLED_MODELS` (§18). Resolution order is **funder
|
||||
universe ∩ §6.6 per-entry list ∩ collection universe ∩ project universe**,
|
||||
with the collection universe substituting for the deployment universe at the
|
||||
outermost step. A collection's universe may only narrow, never widen, its
|
||||
project's; the project's may only narrow the deployment's.
|
||||
|
||||
### 22.13 Migration — the default project and default collection (N=1)
|
||||
|
||||
A deployment on the shipped two-tier schema (v0.39.0) is migrated so it keeps
|
||||
running unchanged:
|
||||
|
||||
1. The existing `projects` row **stays as the project** (it already owns
|
||||
`content_repo` and its config-derived `id` from the §22.13 re-stamp).
|
||||
2. A **default collection** (`id='default'`, `subfolder` = repo root) is
|
||||
created per project, inheriting that project's `type` / `initial_state` /
|
||||
visibility; those per-corpus fields are then dropped from `projects`.
|
||||
3. Every entry-scoped row is re-keyed `(project_id, slug)` →
|
||||
`(collection_id, slug)` via the migration-028 rebuild pattern.
|
||||
4. `project_members` rows migrate to `memberships(scope_type='collection')` on
|
||||
the default collection, role-collapsed (§22.6).
|
||||
5. **308 redirects:** the shipped `/p/<project>/e/<slug>` →
|
||||
`/p/<project>/c/<default>/e/<slug>`, and the pre-multi-project `/rfc/<slug>`
|
||||
/ `/proposals/<n>` → their `/p/<project>/c/<default>/…` equivalents.
|
||||
|
||||
Until a second collection is added, the deployment is functionally identical
|
||||
to before, with one extra path segment. This is the §20.4 upgrade-steps
|
||||
content for the release.
|
||||
|
||||
### 22.14 Amendments to §§1–21 (applied in place)
|
||||
|
||||
The single-corpus sections defer to §22; the load-bearing reinterpretations:
|
||||
|
||||
- **§1 Repository topology.** Each *project* names one content repo; the
|
||||
deployment's registry (§22.2) lists them; collections are subfolders within
|
||||
a project's repo (§22.3). The bot and app-owned-authorization paragraphs are
|
||||
unchanged and now read org-wide.
|
||||
- **§2 Schema / §2.3 IDs.** Slugs are unique **per collection**; the entry
|
||||
**metadata schema is collection-configured**, not type-driven (§22.4a, as
|
||||
amended by v0.46.2) — a `.collection.yaml` `fields:` schema + per-entry
|
||||
`<slug>.meta.yaml` sidecars. The `RFC-NNNN` `max+1` allocation is
|
||||
**removed** — the slug is the identity (§22.4). New `active`-entry fields:
|
||||
`unreviewed` (bool) and the `reviewed_at`/`reviewed_by` provenance pair
|
||||
(§22.4c).
|
||||
- **§2.4 State machine.** The `(no entry) ─[idea-PR merged]→` transition
|
||||
targets the collection's `initial_state` (§22.4b); a new `active
|
||||
─[mark-reviewed, Owner]→ active` self-transition clears the `unreviewed`
|
||||
flag (§22.4c).
|
||||
- **§5 Data model.** `project_id` becomes **`collection_id`** on every
|
||||
entry-scoped row (the corpus grain is now the collection); a separate
|
||||
`project_id` exists only on the `collections` table and project-scoped rows.
|
||||
`cached_rfcs` PK → `(collection_id, slug)` and mirrors the `unreviewed`
|
||||
frontmatter flag. New tables: `projects`, `collections` (carrying the
|
||||
immutable `type`, §22.4a), and `memberships` (§22.6, replacing
|
||||
`project_members`). `users.role` is annotated deployment-scope (§22.7).
|
||||
- **§6 Permission model.** Deployment roles are the global tier of the §22.7
|
||||
four-layer union; a plain `contributor` has no implicit write at any
|
||||
explicitly-created scope until a `memberships` grant gives it one (the N=1
|
||||
default-collection baseline is the sole carve-out, §22.6 keystone note).
|
||||
`project_admin`'s delegation idea is now **Owner** at project/collection
|
||||
scope (§22.6), sitting above per-RFC authority (§6.3).
|
||||
- **§7 / §8.1 / §13.3.** The catalog is per-collection under
|
||||
`/p/<project>/c/<collection>/`; the project collection-directory and the
|
||||
deployment directory (§22.10) sit above it; the §7 catalog gains the
|
||||
unreviewed filter (§22.4c). The §8.1 breadcrumb gains leading project +
|
||||
collection segments. §13.3 graduation operates on the collection's content
|
||||
subfolder, allocates no number, and is a no-op (replaced by mark-reviewed)
|
||||
for collections whose `initial_state` is `active`.
|
||||
- **§14.1 / §17 / §18 / §20.** The landing splits into deployment directory,
|
||||
project collection-directory, and per-collection philosophy/deck. §17 routes
|
||||
gain the `/p/<project>/c/<collection>/` scoping plus `GET /api/deployment`,
|
||||
`GET /api/projects/:id`, `GET /api/projects/:id/collections[/:cid]`, the
|
||||
`memberships` management + request-to-join endpoints, and the mark-reviewed
|
||||
endpoint. `ENABLED_MODELS` is the deployment fallback under §22.12.
|
||||
`VITE_APP_NAME` is deprecated and `REGISTRY_REPO` is a required env var
|
||||
(§20.3); `META_REPO` is legacy, consulted only by the §22.13 migration.
|
||||
|
||||
|
||||
@@ -130,3 +130,16 @@ CLOUDFLARE_TURNSTILE_SECRET=
|
||||
# config drift surfaces as a loud 500 rather than a silent abuse-
|
||||
# defense disablement.
|
||||
TURNSTILE_REQUIRED=false
|
||||
|
||||
# --- Deployed-environment E2E test auth (v0.52.0) ---
|
||||
# DANGER: NEVER set these on a production deployment. Together they
|
||||
# enable `POST /auth/test/login`, which mints an authenticated OWNER
|
||||
# session for the one configured email without any OTC/email round trip
|
||||
# — it exists only to run the Playwright E2E suite against a deployed
|
||||
# pre-prod (PPE) host that has no Mailpit sink. The route is fail-closed:
|
||||
# it returns 404 unless BOTH vars below are set, requires the caller to
|
||||
# present E2E_TEST_AUTH_SECRET in the `X-Test-Auth-Secret` header
|
||||
# (constant-time compare), and only ever mints the single configured
|
||||
# email (any other → 403). Leave BOTH unset everywhere except PPE.
|
||||
# E2E_TEST_AUTH_EMAIL=e2e-owner@example.test
|
||||
# E2E_TEST_AUTH_SECRET= # a Secret Manager ref on real deployments; never a literal here
|
||||
|
||||
+77
-11
@@ -27,6 +27,9 @@ from . import (
|
||||
api_discussion,
|
||||
api_graduation,
|
||||
api_invitations,
|
||||
api_join_requests,
|
||||
api_memberships,
|
||||
api_metadata,
|
||||
api_notifications,
|
||||
api_prs,
|
||||
auth,
|
||||
@@ -39,6 +42,7 @@ from . import (
|
||||
docs_specs,
|
||||
entry as entry_mod,
|
||||
cache,
|
||||
facets,
|
||||
funder,
|
||||
health,
|
||||
notify,
|
||||
@@ -127,6 +131,8 @@ def make_router(
|
||||
router.include_router(api_prs.make_router(config, gitea, bot, providers))
|
||||
# Slice 5: §13 graduation + §13.1 claim.
|
||||
router.include_router(api_graduation.make_router(config, gitea, bot))
|
||||
# §22.4a SLICE-4/5: entry metadata edit + Owner-gated collection migrate.
|
||||
router.include_router(api_metadata.make_router(config, gitea, bot))
|
||||
# Slice 6: §15 notifications surface (inbox, watches, prefs,
|
||||
# quiet hours, per-user mute, email unsubscribe, bounce webhook).
|
||||
router.include_router(api_notifications.make_router(config))
|
||||
@@ -152,8 +158,15 @@ def make_router(
|
||||
router.include_router(api_contributions.make_router())
|
||||
# §22.9/§22.10 (M3): runtime deployment + per-project config (replaces
|
||||
# VITE_APP_NAME) + the old-URL 308 redirects.
|
||||
router.include_router(api_deployment.make_router(config))
|
||||
router.include_router(api_deployment.make_router(config, gitea, bot))
|
||||
router.include_router(api_collections.make_router(config, gitea, bot))
|
||||
# §22 S4 (C.2): the scope-role invitation surface — Owners grant
|
||||
# {owner, contributor} at project/collection scope to existing accounts.
|
||||
router.include_router(api_memberships.make_router())
|
||||
# §22.8 S6: request-to-join + the cross-collection inbox — a user asks into a
|
||||
# scope (naming a role); the scope's Owners across the subtree accept (writing
|
||||
# the membership row) or decline.
|
||||
router.include_router(api_join_requests.make_router())
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §17: /api/health — unauthenticated post-flight probe.
|
||||
@@ -642,6 +655,7 @@ def make_router(
|
||||
f"""
|
||||
SELECT r.slug, r.title, r.state, r.rfc_id, r.repo,
|
||||
r.owners_json, r.arbiters_json, r.tags_json,
|
||||
r.metadata_malformed,
|
||||
r.last_main_commit_at, r.last_entry_commit_at, r.updated_at
|
||||
FROM cached_rfcs r JOIN collections c ON c.id = r.collection_id
|
||||
WHERE r.state IN ('super-draft', 'active')
|
||||
@@ -675,6 +689,7 @@ def make_router(
|
||||
"last_active_at": r["last_main_commit_at"] or r["last_entry_commit_at"] or r["updated_at"],
|
||||
"starred_by_me": r["slug"] in starred,
|
||||
"has_open_prs": False, # wired in Slice 2 when per-RFC repos exist
|
||||
"metadata_malformed": bool(r["metadata_malformed"]),
|
||||
}
|
||||
)
|
||||
return {"items": items}
|
||||
@@ -709,6 +724,9 @@ def make_router(
|
||||
(slug,),
|
||||
).fetchone()
|
||||
payload["proposed_use_case"] = uc["use_case"] if uc else None
|
||||
# §22.4a SLICE-4: contributor+ on the entry's collection may edit metadata.
|
||||
payload["can_edit_meta"] = bool(
|
||||
auth.can_contribute_in_collection(viewer, auth.collection_of_rfc(slug)))
|
||||
return payload
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
@@ -726,16 +744,41 @@ def make_router(
|
||||
raise HTTPException(404, "Not found")
|
||||
|
||||
def _list_rfcs_for_collection(
|
||||
collection_id: str, viewer, unreviewed: str | None
|
||||
collection_id: str, viewer, unreviewed: str | None,
|
||||
query_params=None,
|
||||
) -> dict[str, Any]:
|
||||
viewer_id = viewer.user_id if viewer else None
|
||||
# §22.4a SLICE-3: the collection's declared field schema drives the facet
|
||||
# set (None when undeclared → no facets, INV-5).
|
||||
col = collections_mod.get_collection(collection_id)
|
||||
fields_schema = (col or {}).get("fields") or None
|
||||
|
||||
# Parse + validate filter selections from the query string. Unknown
|
||||
# field → 400 (§6.4). `unreviewed` keeps its existing meaning; an
|
||||
# empty-valued selection is ignored, not an error (plan decision 6).
|
||||
selections: dict[str, set[str]] = {}
|
||||
only_malformed = False
|
||||
if query_params is not None:
|
||||
allowed = facets.allowed_filter_keys(fields_schema)
|
||||
facet_names = {n for n, _ in facets.facet_fields(fields_schema)}
|
||||
for key in query_params.keys():
|
||||
if key not in allowed:
|
||||
raise HTTPException(400, f"unknown filter field {key!r}")
|
||||
if (query_params.get("malformed") or "").lower() in ("1", "true", "yes"):
|
||||
only_malformed = True
|
||||
for name in facet_names:
|
||||
vals = {v for v in query_params.getlist(name) if v != ""}
|
||||
if vals:
|
||||
selections[name] = vals
|
||||
|
||||
unreviewed_clause = ""
|
||||
if unreviewed is not None and unreviewed.lower() in ("1", "true", "yes"):
|
||||
unreviewed_clause = " AND unreviewed = 1 AND state = 'active'"
|
||||
rows = db.conn().execute(
|
||||
f"""
|
||||
SELECT slug, title, state, rfc_id, repo,
|
||||
owners_json, arbiters_json, tags_json,
|
||||
owners_json, arbiters_json, tags_json, metadata_malformed,
|
||||
meta_json,
|
||||
last_main_commit_at, last_entry_commit_at, updated_at
|
||||
FROM cached_rfcs
|
||||
WHERE state IN ('super-draft', 'active')
|
||||
@@ -753,8 +796,16 @@ def make_router(
|
||||
(viewer_id, collection_id),
|
||||
)
|
||||
}
|
||||
items = [
|
||||
{
|
||||
|
||||
# Build entry dicts the facet helper understands (state + malformed +
|
||||
# parsed meta), preserving SQL order.
|
||||
entries = []
|
||||
for r in rows:
|
||||
try:
|
||||
meta = json.loads(r["meta_json"]) if r["meta_json"] else {}
|
||||
except (TypeError, ValueError):
|
||||
meta = {}
|
||||
entries.append({
|
||||
"slug": r["slug"],
|
||||
"title": r["title"],
|
||||
"state": r["state"],
|
||||
@@ -766,10 +817,14 @@ def make_router(
|
||||
"last_active_at": r["last_main_commit_at"] or r["last_entry_commit_at"] or r["updated_at"],
|
||||
"starred_by_me": r["slug"] in starred,
|
||||
"has_open_prs": False,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return {"items": items}
|
||||
"metadata_malformed": bool(r["metadata_malformed"]),
|
||||
"meta": meta,
|
||||
})
|
||||
|
||||
filtered, facet_counts = facets.filter_and_count(
|
||||
entries, fields_schema, selections, only_malformed=only_malformed
|
||||
)
|
||||
return {"items": filtered, "facets": facet_counts}
|
||||
|
||||
def _get_rfc_for_collection(collection_id: str, slug: str, viewer) -> dict[str, Any]:
|
||||
row = db.conn().execute(
|
||||
@@ -790,6 +845,9 @@ def make_router(
|
||||
(slug, collection_id),
|
||||
).fetchone()
|
||||
payload["proposed_use_case"] = uc["use_case"] if uc else None
|
||||
# §22.4a SLICE-4: contributor+ on the collection may edit metadata (INV-4).
|
||||
payload["can_edit_meta"] = bool(
|
||||
auth.can_contribute_in_collection(viewer, collection_id))
|
||||
return payload
|
||||
|
||||
@router.get("/api/projects/{project_id}/rfcs")
|
||||
@@ -801,7 +859,9 @@ def make_router(
|
||||
auth.require_project_readable(viewer, project_id)
|
||||
# §22 S1: the project-scoped route serves the default collection.
|
||||
collection_id = collections_mod.default_collection_id(project_id)
|
||||
return _list_rfcs_for_collection(collection_id, viewer, unreviewed)
|
||||
return _list_rfcs_for_collection(
|
||||
collection_id, viewer, unreviewed, query_params=request.query_params
|
||||
)
|
||||
|
||||
@router.get("/api/projects/{project_id}/rfcs/{slug}")
|
||||
async def get_project_rfc(project_id: str, slug: str, request: Request) -> dict[str, Any]:
|
||||
@@ -823,7 +883,9 @@ def make_router(
|
||||
_require_collection_in_project(collection_id, project_id)
|
||||
# §22.5 (S3): a hidden/gated collection 404s to a non-scope-role viewer.
|
||||
auth.require_collection_readable(viewer, collection_id)
|
||||
return _list_rfcs_for_collection(collection_id, viewer, unreviewed)
|
||||
return _list_rfcs_for_collection(
|
||||
collection_id, viewer, unreviewed, query_params=request.query_params
|
||||
)
|
||||
|
||||
@router.get("/api/projects/{project_id}/collections/{collection_id}/rfcs/{slug}")
|
||||
async def get_collection_rfc(
|
||||
@@ -1333,6 +1395,10 @@ def _serialize_rfc(row) -> dict[str, Any]:
|
||||
"arbiters": json.loads(row["arbiters_json"] or "[]"),
|
||||
"tags": json.loads(row["tags_json"] or "[]"),
|
||||
"body": row["body"] or "",
|
||||
"metadata_malformed": bool(row["metadata_malformed"]),
|
||||
# §22.4a SLICE-4: the full per-entry metadata mapping (known + custom
|
||||
# fields) so the detail panel can render schema-driven controls.
|
||||
"meta": json.loads(row["meta_json"] or "{}"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
+36
-21
@@ -29,7 +29,7 @@ from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, models_resolver, projects as projects_mod
|
||||
from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, metadata as metadata_mod, models_resolver, projects as projects_mod
|
||||
from .bot import Bot
|
||||
from .config import Config
|
||||
from .gitea import Gitea, GiteaError
|
||||
@@ -40,6 +40,37 @@ log = logging.getLogger(__name__)
|
||||
RFC_FILE_PATH = "RFC.md"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# §22.4a SLICE-4: sidecar-aware body extract/wrap (pure, unit-testable)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _extract_body_pure(rfc, file_contents: str, branch: str, *, is_meta: bool) -> str:
|
||||
"""Editable body of an entry file. Meta-resident files carry a frontmatter
|
||||
envelope (legacy) or are already body-only (migrated, §22.4a); per-RFC repo
|
||||
files are body-only. Dual-read tolerant: a body-only `.md` returns as-is."""
|
||||
if not is_meta:
|
||||
return file_contents
|
||||
return metadata_mod.strip_frontmatter(file_contents)
|
||||
|
||||
|
||||
def _wrap_body_pure(rfc, prior_contents: str, new_body: str, branch: str, *, is_meta: bool) -> str:
|
||||
"""Inverse of `_extract_body_pure`. Under §22.4a the body lives in the `.md`
|
||||
and metadata in the sidecar, so wrapping is identity for body-only files —
|
||||
frontmatter is never re-grown here. A legacy un-migrated meta file still has
|
||||
its metadata in the `.md` frontmatter (no sidecar yet), so preserve it rather
|
||||
than silently dropping it on a pure body edit; it is migrated to body-only on
|
||||
its next *metadata* edit."""
|
||||
nb = new_body if new_body.endswith("\n") else new_body + "\n"
|
||||
if not is_meta:
|
||||
return nb
|
||||
if entry_mod.FRONTMATTER_RE.match(prior_contents):
|
||||
e = entry_mod.parse(prior_contents)
|
||||
e.body = nb
|
||||
return entry_mod.serialize(e)
|
||||
return nb
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request bodies
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1163,28 +1194,12 @@ def make_router(
|
||||
return RFC_FILE_PATH
|
||||
|
||||
def _extract_body(rfc, file_contents: str, branch: str = "main") -> str:
|
||||
"""For super-draft entries (and active-RFC pre-graduation reads
|
||||
per §9.8) the file on disk is the full frontmatter+body envelope;
|
||||
the editable body is entry.body. For active RFCs reading their
|
||||
per-RFC repo the file is just RFC.md and the whole thing is body."""
|
||||
if not _is_meta_target(rfc, branch):
|
||||
return file_contents
|
||||
try:
|
||||
entry = entry_mod.parse(file_contents)
|
||||
except Exception:
|
||||
return file_contents
|
||||
return entry.body
|
||||
return _extract_body_pure(
|
||||
rfc, file_contents, branch, is_meta=_is_meta_target(rfc, branch))
|
||||
|
||||
def _wrap_body(rfc, prior_contents: str, new_body: str, branch: str = "main") -> str:
|
||||
"""Inverse of _extract_body: re-wrap a new body into the entry
|
||||
envelope, preserving the prior frontmatter exactly."""
|
||||
if not _is_meta_target(rfc, branch):
|
||||
return new_body
|
||||
entry = entry_mod.parse(prior_contents)
|
||||
# Ensure exactly one trailing newline so the serializer's
|
||||
# round-trip is stable.
|
||||
entry.body = new_body if new_body.endswith("\n") else new_body + "\n"
|
||||
return entry_mod.serialize(entry)
|
||||
return _wrap_body_pure(
|
||||
rfc, prior_contents, new_body, branch, is_meta=_is_meta_target(rfc, branch))
|
||||
|
||||
async def _refresh_cache_for(rfc) -> None:
|
||||
if _is_meta_resident(rfc):
|
||||
|
||||
@@ -41,6 +41,34 @@ class CreateCollectionBody(BaseModel):
|
||||
initial_state: str | None = None
|
||||
|
||||
|
||||
def _project_viewer_caps(viewer: Any, project_id: str) -> dict[str, Any]:
|
||||
"""§22 S4: the viewer's project-grain capabilities for role-aware UI — may
|
||||
they create a collection, may they manage membership (invite), and their
|
||||
project role. `role` maps the §22.6 legacy strings back to the unified
|
||||
`{owner, contributor}` vocabulary the frontend speaks."""
|
||||
legacy = auth.project_member_role(viewer, project_id)
|
||||
role = None
|
||||
if viewer is not None and viewer.role in ("owner", "admin"):
|
||||
role = "owner"
|
||||
elif legacy == "project_admin":
|
||||
role = "owner"
|
||||
elif legacy == "project_contributor":
|
||||
role = "contributor"
|
||||
return {
|
||||
"can_create_collection": auth.can_create_collection(viewer, project_id),
|
||||
"can_invite": auth.can_invite_at_project(viewer, project_id),
|
||||
# §22.8: a signed-in, granted account with no role at the project may ask
|
||||
# to join it (the request-to-join affordance). Owners/members and
|
||||
# not-yet-granted accounts don't see it.
|
||||
"can_request_join": (
|
||||
viewer is not None
|
||||
and viewer.permission_state == "granted"
|
||||
and auth.effective_role_at_scope(viewer, "project", project_id) is None
|
||||
),
|
||||
"role": role,
|
||||
}
|
||||
|
||||
|
||||
def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
@@ -57,7 +85,10 @@ def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
|
||||
for c in collections_mod.list_collections(project_id, include_unlisted=True)
|
||||
if c["visibility"] != "unlisted" and auth.can_read_collection(viewer, c["id"])
|
||||
]
|
||||
return {"items": items}
|
||||
# §22 S4: surface the viewer's project-level capabilities so the
|
||||
# directory can render role-aware affordances (the create-first-
|
||||
# collection CTA, the invite control) without a second round-trip.
|
||||
return {"items": items, "viewer": _project_viewer_caps(viewer, project_id)}
|
||||
|
||||
@router.get("/api/projects/{project_id}/collections/{collection_id}")
|
||||
async def get_col(project_id: str, collection_id: str, request: Request) -> dict[str, Any]:
|
||||
@@ -68,6 +99,21 @@ def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
|
||||
raise HTTPException(404, "Not found")
|
||||
# §22.5 (S3): a hidden/gated collection 404s a non-scope-role viewer.
|
||||
auth.require_collection_readable(viewer, collection_id)
|
||||
# §22 S4: the viewer's collection-level capabilities drive the
|
||||
# propose-first empty state and the collection invite control.
|
||||
col = dict(col)
|
||||
col["viewer"] = {
|
||||
"can_contribute": auth.can_contribute_in_collection(viewer, collection_id),
|
||||
"can_invite": auth.can_invite_at_collection(viewer, collection_id),
|
||||
# §22.8: a signed-in, granted account with no role reaching this
|
||||
# collection may ask to join it.
|
||||
"can_request_join": (
|
||||
viewer is not None
|
||||
and viewer.permission_state == "granted"
|
||||
and auth.effective_scope_role(viewer, collection_id) is None
|
||||
),
|
||||
"role": auth.effective_scope_role(viewer, collection_id),
|
||||
}
|
||||
return col
|
||||
|
||||
@router.post("/api/projects/{project_id}/collections")
|
||||
|
||||
@@ -1,28 +1,60 @@
|
||||
"""§22.9 runtime deployment/project config (replaces VITE_APP_NAME) + §22.10
|
||||
old-URL 308 redirects.
|
||||
old-URL 308 redirects + §22 S5 in-app create-project.
|
||||
|
||||
GET /api/deployment — the deployment name/tagline + the projects the caller can
|
||||
GET /api/deployment — the deployment name/tagline + the projects the caller can
|
||||
see (§22.5: gated filtered by membership, unlisted omitted from enumeration),
|
||||
plus the corpus-served `default_project_id` the M3-frontend guard keys on.
|
||||
GET /api/projects/:id — one project's runtime config + optional theme overlay,
|
||||
plus the corpus-served `default_project_id` the M3-frontend guard keys on, the
|
||||
`viewer` capability block (S5: `can_create_project`), and
|
||||
`default_project_readable` (whether the N=1 redirect target is reachable by this
|
||||
viewer — drives the deployment-directory empty state vs the land-in-corpus
|
||||
redirect).
|
||||
POST /api/projects — §22 S5 create-project (global-Owner only). The bot
|
||||
provisions a Gitea content repo and commits a project entry to `projects.yaml`;
|
||||
the registry mirror then upserts the `projects` + default `collections` rows
|
||||
(§22.2 keeps the registry the source of truth).
|
||||
GET /api/projects/:id — one project's runtime config + optional theme overlay,
|
||||
gated behind the §22.5 read gate (404 for a non-member of a gated project).
|
||||
GET /rfc/{slug}, /proposals/{n} — §22.10 server-side 308s onto the new
|
||||
GET /rfc/{slug}, /proposals/{n} — §22.10 server-side 308s onto the new
|
||||
`/p/<default>/…` routes (the SPA no longer owns these paths; nginx proxies them
|
||||
to the backend instead of serving index.html).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from . import auth, collections as collections_mod, db, projects as projects_mod
|
||||
from . import (
|
||||
auth,
|
||||
collections as collections_mod,
|
||||
db,
|
||||
projects as projects_mod,
|
||||
registry as registry_mod,
|
||||
)
|
||||
from .bot import Bot
|
||||
from .config import Config
|
||||
from .gitea import Gitea, GiteaError
|
||||
|
||||
# A project id is a slug (the §22.2 registry key + the `/p/<id>/` path segment).
|
||||
_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
|
||||
# A Gitea repo name: alphanumeric start, then alphanumerics / `-` / `_` / `.`.
|
||||
_REPO_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
||||
|
||||
|
||||
def make_router(config: Config) -> APIRouter:
|
||||
class CreateProjectBody(BaseModel):
|
||||
project_id: str
|
||||
name: str
|
||||
type: str
|
||||
visibility: str | None = None
|
||||
content_repo: str | None = None
|
||||
|
||||
|
||||
def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/api/deployment")
|
||||
@@ -46,11 +78,26 @@ def make_router(config: Config) -> APIRouter:
|
||||
"type": collections_mod.collection_type(
|
||||
collections_mod.default_collection_id(r["id"])
|
||||
),
|
||||
"entry_noun": collections_mod.entry_noun(
|
||||
collections_mod.collection_type(
|
||||
collections_mod.default_collection_id(r["id"])
|
||||
)
|
||||
),
|
||||
"visibility": r["visibility"],
|
||||
}
|
||||
for r in rows
|
||||
if r["id"] in visible
|
||||
]
|
||||
# §22 S5: the N=1 land-in-corpus redirect targets the default project, but
|
||||
# only when this viewer can actually read it. A `gated` default (C3.2) or
|
||||
# an absent default (C3.1, a deployment with no projects) is *not* a valid
|
||||
# redirect target — the frontend then falls through to the deployment
|
||||
# directory's role-aware empty state instead of bouncing into a 404.
|
||||
default_id = projects_mod.resolved_default_id(config)
|
||||
default_exists = db.conn().execute(
|
||||
"SELECT 1 FROM projects WHERE id = ?", (default_id,)
|
||||
).fetchone() is not None
|
||||
default_readable = default_exists and auth.can_read_project(viewer, default_id)
|
||||
return {
|
||||
"name": (dep["name"] if dep else "") or "",
|
||||
"tagline": (dep["tagline"] if dep else "") or "",
|
||||
@@ -58,8 +105,100 @@ def make_router(config: Config) -> APIRouter:
|
||||
# serves the corpus for (the default, until Plan B serves per
|
||||
# project). The frontend renders corpus routes only for this id and
|
||||
# shows a "content not yet served" placeholder for any other.
|
||||
"default_project_id": projects_mod.resolved_default_id(config),
|
||||
"default_project_id": default_id,
|
||||
"default_project_readable": default_readable,
|
||||
"projects": projects,
|
||||
# §22 S5 (C3.1/C3.2): role-aware deployment-directory affordances.
|
||||
"viewer": {"can_create_project": auth.can_create_project(viewer)},
|
||||
}
|
||||
|
||||
@router.post("/api/projects")
|
||||
async def create_project(body: CreateProjectBody, request: Request) -> dict[str, Any]:
|
||||
# §22 S5 / §A.2 / §B.1: "+ New project" is a global-Owner action.
|
||||
user = auth.require_contributor(request)
|
||||
if not auth.can_create_project(user):
|
||||
raise HTTPException(403, "Only a global Owner may create projects")
|
||||
pid = body.project_id.strip().lower()
|
||||
if not _SLUG_RE.match(pid) or pid == "default":
|
||||
raise HTTPException(422, "project id must be a slug and not 'default'")
|
||||
name = (body.name or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(422, "project name is required")
|
||||
if body.type not in registry_mod.VALID_TYPES:
|
||||
raise HTTPException(422, f"invalid type {body.type!r}")
|
||||
# A project is created visible by default — the point of standing one up
|
||||
# is for it to be seen; an Owner narrows it afterwards (or picks gated).
|
||||
visibility = (body.visibility or "public").strip()
|
||||
if visibility not in registry_mod.VALID_VISIBILITY:
|
||||
raise HTTPException(422, f"invalid visibility {visibility!r}")
|
||||
if db.conn().execute("SELECT 1 FROM projects WHERE id = ?", (pid,)).fetchone():
|
||||
raise HTTPException(409, f"project `{pid}` already exists")
|
||||
content_repo = (body.content_repo or f"{pid}-content").strip()
|
||||
if not _REPO_RE.match(content_repo):
|
||||
raise HTTPException(422, f"invalid content repo name {content_repo!r}")
|
||||
if await gitea.get_repo(config.gitea_org, content_repo) is not None:
|
||||
raise HTTPException(409, f"repo `{content_repo}` already exists")
|
||||
|
||||
# Read the current registry, append the project, recompose. Reads live in
|
||||
# gitea.py and may be called anywhere; the bot owns the write back.
|
||||
read = await gitea.read_file(
|
||||
config.gitea_org, config.registry_repo, "projects.yaml", ref="main"
|
||||
)
|
||||
if read is None:
|
||||
raise HTTPException(409, "registry projects.yaml not found")
|
||||
text, sha = read
|
||||
try:
|
||||
doc = yaml.safe_load(text) or {}
|
||||
except yaml.YAMLError as e:
|
||||
raise HTTPException(500, f"registry projects.yaml is not valid YAML: {e}")
|
||||
if not isinstance(doc, dict):
|
||||
raise HTTPException(500, "registry projects.yaml is malformed")
|
||||
projects = doc.get("projects")
|
||||
if not isinstance(projects, list):
|
||||
projects = []
|
||||
if any(isinstance(p, dict) and str(p.get("id") or "") == pid for p in projects):
|
||||
raise HTTPException(409, f"project `{pid}` already in the registry")
|
||||
projects.append(
|
||||
{
|
||||
"id": pid,
|
||||
"name": name,
|
||||
"type": body.type,
|
||||
"content_repo": content_repo,
|
||||
"visibility": visibility,
|
||||
}
|
||||
)
|
||||
doc["projects"] = projects
|
||||
new_text = yaml.safe_dump(doc, sort_keys=False)
|
||||
readme_text = f"# {name}\n\nContent repository for project `{pid}`.\n"
|
||||
|
||||
try:
|
||||
await bot.create_project(
|
||||
user.as_actor(),
|
||||
org=config.gitea_org,
|
||||
registry_repo=config.registry_repo,
|
||||
content_repo=content_repo,
|
||||
project_id=pid,
|
||||
projects_yaml_new=new_text,
|
||||
projects_yaml_sha=sha,
|
||||
readme_text=readme_text,
|
||||
)
|
||||
except GiteaError as e:
|
||||
raise HTTPException(502, f"Gitea: {e.detail}")
|
||||
|
||||
# §22.2: re-read the registry so the new entry becomes projects +
|
||||
# default-collection rows.
|
||||
await registry_mod.refresh_registry(config, gitea)
|
||||
row = db.conn().execute(
|
||||
"SELECT id, name, visibility FROM projects WHERE id = ?", (pid,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(500, "project committed but not mirrored")
|
||||
cid = collections_mod.default_collection_id(pid)
|
||||
return {
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"visibility": row["visibility"],
|
||||
"type": collections_mod.collection_type(cid),
|
||||
}
|
||||
|
||||
@router.get("/api/projects/{project_id}")
|
||||
@@ -87,6 +226,7 @@ def make_router(config: Config) -> APIRouter:
|
||||
"name": row["name"],
|
||||
"tagline": (dep["tagline"] if dep else "") or "",
|
||||
"type": collections_mod.collection_type(cid),
|
||||
"entry_noun": collections_mod.entry_noun(collections_mod.collection_type(cid)),
|
||||
"visibility": row["visibility"],
|
||||
"initial_state": collections_mod.collection_initial_state(cid),
|
||||
"theme": cfg.get("theme") or {},
|
||||
|
||||
@@ -42,7 +42,7 @@ from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import auth, cache, db, entry as entry_mod, projects as projects_mod
|
||||
from . import auth, cache, db, entry as entry_mod, metadata as metadata_mod, projects as projects_mod
|
||||
from .bot import Actor, Bot
|
||||
from .config import Config
|
||||
from .gitea import Gitea, GiteaError
|
||||
@@ -341,19 +341,16 @@ def make_router(
|
||||
if _rfc_id_taken(rfc_id, excluding_slug=slug):
|
||||
raise HTTPException(409, f"Integer ID {rfc_id} is already taken")
|
||||
|
||||
# Read the meta-repo entry once — we need the file's sha for the
|
||||
# graduation PR's update_file call and the body to carry through
|
||||
# Dual-read the meta-repo entry once (§22.4a sidecar-aware) — we need its
|
||||
# git state for the graduation commit and the body to carry through
|
||||
# unchanged (meta-only keeps the body in the entry, §13.3).
|
||||
fetched = await gitea.read_file(
|
||||
config.gitea_org, (projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md", ref="main",
|
||||
st = await metadata_mod.read_entry_from_git(
|
||||
gitea, config.gitea_org,
|
||||
(projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md",
|
||||
)
|
||||
if fetched is None:
|
||||
if st is None:
|
||||
raise HTTPException(409, f"Meta entry rfcs/{slug}.md not found on main")
|
||||
meta_text, meta_sha = fetched
|
||||
try:
|
||||
super_draft_entry = entry_mod.parse(meta_text)
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Meta entry malformed: {e}")
|
||||
super_draft_entry = st.entry
|
||||
|
||||
arbiters = json.loads(rfc["arbiters_json"] or "[]") or owners[:1]
|
||||
|
||||
@@ -376,8 +373,12 @@ def make_router(
|
||||
models=super_draft_entry.models,
|
||||
funder=super_draft_entry.funder,
|
||||
body=super_draft_entry.body,
|
||||
# INV-7 (§22.4a): carry forward-compat / unknown frontmatter keys
|
||||
# through graduation rather than dropping them on the rebuild.
|
||||
extra=dict(super_draft_entry.extra),
|
||||
)
|
||||
graduated_contents = entry_mod.serialize(graduated_entry)
|
||||
graduation_files = metadata_mod.write_entry_files(
|
||||
f"rfcs/{slug}.md", graduated_entry, st)
|
||||
|
||||
state = _new_active(
|
||||
slug, rfc_id=rfc_id, owners=owners, arbiters=arbiters,
|
||||
@@ -397,8 +398,7 @@ def make_router(
|
||||
coro = _orchestrate(
|
||||
config=config, gitea=gitea, bot=bot,
|
||||
actor=viewer.as_actor(), state=state,
|
||||
graduated_contents=graduated_contents,
|
||||
meta_file_sha=meta_sha,
|
||||
graduation_files=graduation_files,
|
||||
)
|
||||
if request.query_params.get("_sync") == "1":
|
||||
await coro
|
||||
@@ -478,26 +478,23 @@ def make_router(
|
||||
if already:
|
||||
raise HTTPException(409, f"A claim PR is already open: #{already['pr_number']}")
|
||||
|
||||
fetched = await gitea.read_file(
|
||||
config.gitea_org, (projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md", ref="main",
|
||||
st = await metadata_mod.read_entry_from_git(
|
||||
gitea, config.gitea_org,
|
||||
(projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md",
|
||||
)
|
||||
if fetched is None:
|
||||
if st is None:
|
||||
raise HTTPException(409, f"Meta entry rfcs/{slug}.md not found on main")
|
||||
meta_text, meta_sha = fetched
|
||||
try:
|
||||
ent = entry_mod.parse(meta_text)
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Meta entry malformed: {e}")
|
||||
if viewer.gitea_login in ent.owners:
|
||||
if viewer.gitea_login in st.entry.owners:
|
||||
return {"ok": True, "noop": True}
|
||||
ent.owners = ent.owners + [viewer.gitea_login]
|
||||
new_contents = entry_mod.serialize(ent)
|
||||
ent = metadata_mod.apply_values(
|
||||
st.entry, {"owners": st.entry.owners + [viewer.gitea_login]})
|
||||
files = metadata_mod.write_entry_files(f"rfcs/{slug}.md", ent, st)
|
||||
try:
|
||||
pr = await bot.open_claim_pr(
|
||||
viewer.as_actor(),
|
||||
org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""),
|
||||
slug=slug,
|
||||
new_file_contents=new_contents, prior_sha=meta_sha,
|
||||
files=files,
|
||||
)
|
||||
except GiteaError as e:
|
||||
raise HTTPException(502, f"Gitea: {e.detail}")
|
||||
@@ -524,11 +521,12 @@ def make_router(
|
||||
403, "Only this RFC's owners or a site owner may retire it"
|
||||
)
|
||||
prior_state = rfc["state"]
|
||||
entry, sha = await _read_meta_entry(slug)
|
||||
entry.state = "retired"
|
||||
st = await _read_meta_entry(slug)
|
||||
entry = metadata_mod.apply_values(st.entry, {"state": "retired"})
|
||||
files = metadata_mod.write_entry_files(f"rfcs/{slug}.md", entry, st)
|
||||
await _run_state_flip(
|
||||
config=config, gitea=gitea, bot=bot, actor=viewer.as_actor(),
|
||||
slug=slug, new_contents=entry_mod.serialize(entry), prior_sha=sha,
|
||||
slug=slug, files=files,
|
||||
verb="retire", target_state="retired",
|
||||
)
|
||||
_audit(
|
||||
@@ -554,11 +552,12 @@ def make_router(
|
||||
raise HTTPException(403, "Only a site owner may un-retire an RFC")
|
||||
_require_retired(slug)
|
||||
restored = _prior_state_before_retire(slug)
|
||||
entry, sha = await _read_meta_entry(slug)
|
||||
entry.state = restored
|
||||
st = await _read_meta_entry(slug)
|
||||
entry = metadata_mod.apply_values(st.entry, {"state": restored})
|
||||
files = metadata_mod.write_entry_files(f"rfcs/{slug}.md", entry, st)
|
||||
await _run_state_flip(
|
||||
config=config, gitea=gitea, bot=bot, actor=viewer.as_actor(),
|
||||
slug=slug, new_contents=entry_mod.serialize(entry), prior_sha=sha,
|
||||
slug=slug, files=files,
|
||||
verb="unretire", target_state=restored,
|
||||
)
|
||||
_audit(
|
||||
@@ -599,17 +598,17 @@ def make_router(
|
||||
raise HTTPException(409, f"RFC is {row['state']}, not retired")
|
||||
return row
|
||||
|
||||
async def _read_meta_entry(slug: str) -> tuple[entry_mod.Entry, str]:
|
||||
fetched = await gitea.read_file(
|
||||
config.gitea_org, (projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md", ref="main",
|
||||
async def _read_meta_entry(slug: str):
|
||||
"""Dual-read an entry from meta-main → EntryGitState (sidecar-aware,
|
||||
§22.4a). A migrated body-only `.md` reads cleanly; never raises on bad
|
||||
metadata (INV-3)."""
|
||||
st = await metadata_mod.read_entry_from_git(
|
||||
gitea, config.gitea_org,
|
||||
(projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md",
|
||||
)
|
||||
if fetched is None:
|
||||
if st is None:
|
||||
raise HTTPException(409, f"Meta entry rfcs/{slug}.md not found on main")
|
||||
text, file_sha = fetched
|
||||
try:
|
||||
return entry_mod.parse(text), file_sha
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Meta entry malformed: {e}")
|
||||
return st
|
||||
|
||||
async def _refresh_catalog() -> None:
|
||||
# Inline refresh so the catalog reflects the flip immediately; the
|
||||
@@ -637,8 +636,7 @@ async def _orchestrate(
|
||||
bot: Bot,
|
||||
actor: Actor,
|
||||
state: GraduationState,
|
||||
graduated_contents: str,
|
||||
meta_file_sha: str,
|
||||
graduation_files: list[dict],
|
||||
) -> None:
|
||||
"""Open the flip PR, then merge it. Two steps, no transaction:
|
||||
|
||||
@@ -658,8 +656,7 @@ async def _orchestrate(
|
||||
actor,
|
||||
org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""),
|
||||
slug=state.slug,
|
||||
new_file_contents=graduated_contents,
|
||||
prior_sha=meta_file_sha,
|
||||
files=graduation_files,
|
||||
rfc_id=state.rfc_id,
|
||||
owners=state.owners,
|
||||
)
|
||||
@@ -847,12 +844,12 @@ async def _run_state_flip(
|
||||
bot: Bot,
|
||||
actor: Actor,
|
||||
slug: str,
|
||||
new_contents: str,
|
||||
prior_sha: str,
|
||||
files: list[dict],
|
||||
verb: str,
|
||||
target_state: str,
|
||||
) -> None:
|
||||
"""§13.7: open + merge a retire / un-retire frontmatter flip PR. Runs
|
||||
"""§13.7: open + merge a retire / un-retire state-flip PR. The flip is
|
||||
written to the entry's metadata sidecar (§22.4a) via `files` ops. Runs
|
||||
inline (no SSE — the flip is a single quick state change, unlike the
|
||||
multi-step graduation that streams progress). On an open failure
|
||||
nothing was created; on a merge failure the half-open PR/branch is
|
||||
@@ -862,7 +859,7 @@ async def _run_state_flip(
|
||||
pr = await bot.open_retire_flip_pr(
|
||||
actor,
|
||||
org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""),
|
||||
slug=slug, new_file_contents=new_contents, prior_sha=prior_sha,
|
||||
slug=slug, files=files,
|
||||
verb=verb, target_state=target_state,
|
||||
)
|
||||
except GiteaError as e:
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
"""§22.8 S6 — request-to-join a scope + the cross-collection inbox.
|
||||
|
||||
A gated project or collection is invisible to non-members (§22.5), so joining is
|
||||
by invite (an Owner grants directly — `api_memberships.py`) *or* by request: a
|
||||
user who knows a scope exists asks to join it, naming a desired role. This module
|
||||
is the request side:
|
||||
|
||||
* ``GET /api/scopes/{scope_type}/{scope_id}/join-target`` — what the join
|
||||
form needs (the scope's name, the viewer's eligibility + whether they already
|
||||
have a pending ask + their current role).
|
||||
* ``POST /api/scopes/{scope_type}/{scope_id}/join-requests`` — submit the ask
|
||||
(desired role + optional message); lands a row + one §15 notification per
|
||||
Owner across the scope's subtree (the cross-collection inbox, §22.11).
|
||||
* ``POST /api/scopes/{scope_type}/{scope_id}/join-requests/{id}/accept`` —
|
||||
Owner: accept, which writes the `memberships` row via ``memberships.grant``
|
||||
(the §22.8 "accepting writes the membership row"), then notifies the requester.
|
||||
* ``POST /api/scopes/{scope_type}/{scope_id}/join-requests/{id}/decline`` —
|
||||
Owner: decline; the request closes and the requester is notified.
|
||||
|
||||
Mirrors ``api_contributions.py`` (the per-RFC contribute-request flow) but at the
|
||||
scope grain: the target is a ``(scope_type, scope_id)`` pair drawn from the
|
||||
``memberships`` scope vocabulary (minus ``global`` — a deployment isn't a thing
|
||||
one discovers and joins), and accept grants a scope role rather than minting an
|
||||
RFC invitation.
|
||||
|
||||
The request POST deliberately does **not** require the scope be *readable*: the
|
||||
whole point of request-to-join is to ask into a *gated* scope you were told about
|
||||
but cannot see (§22.8). It is gated only on "you're signed in, granted, and not
|
||||
already a member". Accept/decline are gated on Owner reach over the scope
|
||||
(``auth.can_invite_at_project`` / ``auth.can_invite_at_collection``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import (
|
||||
auth,
|
||||
collections as collections_mod,
|
||||
db,
|
||||
memberships as memberships_mod,
|
||||
notify,
|
||||
)
|
||||
|
||||
_MESSAGE_MAX = 4000
|
||||
|
||||
|
||||
class JoinRequestBody(BaseModel):
|
||||
role: str
|
||||
message: str | None = Field(default=None, max_length=_MESSAGE_MAX)
|
||||
|
||||
|
||||
class DecideBody(BaseModel):
|
||||
# On accept, the Owner may grant a role narrower than the one requested; a
|
||||
# missing value grants exactly the requested role.
|
||||
role: str | None = None
|
||||
|
||||
|
||||
def _project_name(project_id: str) -> str | None:
|
||||
row = db.conn().execute(
|
||||
"SELECT name FROM projects WHERE id = ?", (project_id,)
|
||||
).fetchone()
|
||||
return row["name"] if row and row["name"] else None
|
||||
|
||||
|
||||
def _resolve_scope(scope_type: str, scope_id: str) -> dict[str, Any]:
|
||||
"""Resolve a `(scope_type, scope_id)` target to its display facts, or 404 if
|
||||
it doesn't exist. Returns `{project_id, scope_name, project_name}`. The
|
||||
`scope_type` itself must be one of the join-able scopes."""
|
||||
if scope_type == "project":
|
||||
row = db.conn().execute(
|
||||
"SELECT id, name FROM projects WHERE id = ?", (scope_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Not found")
|
||||
name = row["name"] or scope_id
|
||||
return {"project_id": scope_id, "scope_name": name, "project_name": name}
|
||||
if scope_type == "collection":
|
||||
col = collections_mod.get_collection(scope_id)
|
||||
if col is None:
|
||||
raise HTTPException(404, "Not found")
|
||||
pid = col["project_id"]
|
||||
return {
|
||||
"project_id": pid,
|
||||
"scope_name": col.get("name") or scope_id,
|
||||
"project_name": _project_name(pid),
|
||||
}
|
||||
raise HTTPException(404, "Not found")
|
||||
|
||||
|
||||
def _require_join_owner(viewer, scope_type: str, scope_id: str) -> None:
|
||||
"""The accept/decline gate: an Owner whose reach covers the scope (§22.8 'the
|
||||
scope's Owners across the subtree'). Reuses the S4 invite gates."""
|
||||
ok = (
|
||||
auth.can_invite_at_collection(viewer, scope_id)
|
||||
if scope_type == "collection"
|
||||
else auth.can_invite_at_project(viewer, scope_id)
|
||||
)
|
||||
if not ok:
|
||||
raise HTTPException(403, "Only an Owner of this scope can act on join requests")
|
||||
|
||||
|
||||
def _require_request(scope_type: str, scope_id: str, request_id: int):
|
||||
row = db.conn().execute(
|
||||
"""
|
||||
SELECT id, scope_type, scope_id, requester_user_id, requested_role,
|
||||
message, status
|
||||
FROM join_requests
|
||||
WHERE id = ? AND scope_type = ? AND scope_id = ?
|
||||
""",
|
||||
(request_id, scope_type, scope_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Join request not found")
|
||||
return row
|
||||
|
||||
|
||||
def make_router() -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# GET — what the join form needs to render + gate itself.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.get("/api/scopes/{scope_type}/{scope_id}/join-target")
|
||||
async def join_target(scope_type: str, scope_id: str, request: Request) -> dict[str, Any]:
|
||||
facts = _resolve_scope(scope_type, scope_id)
|
||||
viewer = auth.current_user(request)
|
||||
|
||||
eligible = True
|
||||
reason: str | None = None
|
||||
already_requested = False
|
||||
current_role = auth.effective_role_at_scope(viewer, scope_type, scope_id)
|
||||
|
||||
if viewer is None:
|
||||
eligible, reason = False, "Sign in to request to join."
|
||||
elif viewer.permission_state != "granted":
|
||||
eligible, reason = False, "Your beta access request is in review."
|
||||
elif current_role is not None:
|
||||
eligible, reason = False, f"You already hold {('Owner' if current_role == 'owner' else 'RFC Contributor')} here."
|
||||
else:
|
||||
already_requested = bool(
|
||||
db.conn().execute(
|
||||
"""
|
||||
SELECT 1 FROM join_requests
|
||||
WHERE scope_type = ? AND scope_id = ? AND requester_user_id = ?
|
||||
AND status = 'pending' LIMIT 1
|
||||
""",
|
||||
(scope_type, scope_id, viewer.user_id),
|
||||
).fetchone()
|
||||
)
|
||||
|
||||
return {
|
||||
"scope_type": scope_type,
|
||||
"scope_id": scope_id,
|
||||
"name": facts["scope_name"],
|
||||
"project_id": facts["project_id"],
|
||||
"eligible": eligible and not already_requested,
|
||||
"reason": reason,
|
||||
"already_requested": already_requested,
|
||||
"current_role": current_role,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# POST — submit a request to join.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/scopes/{scope_type}/{scope_id}/join-requests")
|
||||
async def create_join_request(
|
||||
scope_type: str, scope_id: str, body: JoinRequestBody, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
facts = _resolve_scope(scope_type, scope_id)
|
||||
|
||||
role = (body.role or "").strip().lower()
|
||||
if role not in memberships_mod.VALID_ROLES:
|
||||
raise HTTPException(422, f"invalid role {body.role!r}")
|
||||
|
||||
# Already a member of the scope (at this or a broader grain)? Then there
|
||||
# is nothing to request — a clear 409 rather than a useless self-request.
|
||||
if auth.effective_role_at_scope(viewer, scope_type, scope_id) is not None:
|
||||
raise HTTPException(409, "You already hold a role in this scope.")
|
||||
|
||||
message = (body.message or "").strip() or None
|
||||
|
||||
try:
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO join_requests
|
||||
(scope_type, scope_id, requester_user_id, requested_role, message)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(scope_type, scope_id, viewer.user_id, role, message),
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
# The partial unique index — one open request per (scope, user).
|
||||
raise HTTPException(409, "You already have a pending request to join this scope.")
|
||||
request_id = cur.lastrowid
|
||||
|
||||
# One actionable notification per Owner across the subtree; stamp the
|
||||
# first onto the row as the inbox-action handle (any Owner may act).
|
||||
notif_ids = notify.fan_out_join_request(
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
scope_name=facts["scope_name"],
|
||||
project_id=facts["project_id"],
|
||||
project_name=facts["project_name"],
|
||||
requester_user_id=viewer.user_id,
|
||||
request_id=request_id,
|
||||
requested_role=role,
|
||||
message=message,
|
||||
)
|
||||
if notif_ids:
|
||||
db.conn().execute(
|
||||
"UPDATE join_requests SET notification_id = ? WHERE id = ?",
|
||||
(notif_ids[0], request_id),
|
||||
)
|
||||
|
||||
return {"id": request_id, "scope_type": scope_type, "scope_id": scope_id, "status": "pending"}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# POST — Owner accepts → write the membership row.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/scopes/{scope_type}/{scope_id}/join-requests/{request_id}/accept")
|
||||
async def accept_join_request(
|
||||
scope_type: str, scope_id: str, request_id: int, body: DecideBody, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
facts = _resolve_scope(scope_type, scope_id)
|
||||
_require_join_owner(viewer, scope_type, scope_id)
|
||||
|
||||
req = _require_request(scope_type, scope_id, request_id)
|
||||
if req["status"] != "pending":
|
||||
raise HTTPException(409, f"This request was already {req['status']}.")
|
||||
|
||||
# The Owner may narrow the requested role on accept; default to what was
|
||||
# asked for. (Both are within the Owner's grant reach at this scope.)
|
||||
granted_role = (body.role or req["requested_role"] or "").strip().lower()
|
||||
if granted_role not in memberships_mod.VALID_ROLES:
|
||||
raise HTTPException(422, f"invalid role {body.role!r}")
|
||||
|
||||
memberships_mod.grant(
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
user_id=req["requester_user_id"],
|
||||
role=granted_role,
|
||||
granted_by=viewer.user_id,
|
||||
)
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE join_requests
|
||||
SET status = 'accepted', decided_at = datetime('now'),
|
||||
decided_by_user_id = ?, granted_role = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(viewer.user_id, granted_role, request_id),
|
||||
)
|
||||
notify.notify_join_decided(
|
||||
requester_user_id=req["requester_user_id"],
|
||||
decider_user_id=viewer.user_id,
|
||||
request_id=request_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
scope_name=facts["scope_name"],
|
||||
granted_role=granted_role,
|
||||
accepted=True,
|
||||
)
|
||||
return {"ok": True, "status": "accepted", "granted_role": granted_role}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# POST — Owner declines.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/scopes/{scope_type}/{scope_id}/join-requests/{request_id}/decline")
|
||||
async def decline_join_request(
|
||||
scope_type: str, scope_id: str, request_id: int, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
facts = _resolve_scope(scope_type, scope_id)
|
||||
_require_join_owner(viewer, scope_type, scope_id)
|
||||
|
||||
req = _require_request(scope_type, scope_id, request_id)
|
||||
if req["status"] != "pending":
|
||||
raise HTTPException(409, f"This request was already {req['status']}.")
|
||||
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE join_requests
|
||||
SET status = 'declined', decided_at = datetime('now'),
|
||||
decided_by_user_id = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(viewer.user_id, request_id),
|
||||
)
|
||||
notify.notify_join_decided(
|
||||
requester_user_id=req["requester_user_id"],
|
||||
decider_user_id=viewer.user_id,
|
||||
request_id=request_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
scope_name=facts["scope_name"],
|
||||
granted_role=None,
|
||||
accepted=False,
|
||||
)
|
||||
return {"ok": True, "status": "declined"}
|
||||
|
||||
return router
|
||||
@@ -0,0 +1,157 @@
|
||||
"""§22 S4 (C.2) — the scope-role invitation surface.
|
||||
|
||||
An Owner grants `{owner, contributor}` at a scope their reach covers — the
|
||||
project, or a single collection within it — to an existing account, looked up
|
||||
by email. The grant writes a `memberships` row immediately and §15-notifies
|
||||
the grantee (there is no accept round-trip; the C.2 scenarios name an existing
|
||||
user and write the row directly). Endpoints:
|
||||
|
||||
GET /api/projects/:pid/members — list the project subtree's grants
|
||||
POST /api/projects/:pid/members — grant at project scope, or
|
||||
(with collection_id) at one collection
|
||||
DELETE /api/projects/:pid/members/:user_id — revoke (optionally ?collection_id=)
|
||||
|
||||
The single POST keys on the optional `collection_id` so the invite UI's one
|
||||
control (role picker + scope picker) maps to one endpoint:
|
||||
|
||||
* no `collection_id` → project-scope grant; gate `can_invite_at_project`.
|
||||
* with `collection_id` → collection-scope grant; gate `can_invite_at_collection`.
|
||||
|
||||
There is deliberately no "grant at parent, exclude a child" parameter (C.2.5):
|
||||
the only knobs are role ∈ {owner, contributor} and scope ∈ {project, one
|
||||
collection}. Reach is bounded by the inviter's own Owner reach (C.2.3): a
|
||||
collection Owner who is nothing more is refused the project-scope POST.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from . import (
|
||||
auth,
|
||||
collections as collections_mod,
|
||||
db,
|
||||
memberships as memberships_mod,
|
||||
notify,
|
||||
)
|
||||
|
||||
|
||||
class GrantBody(BaseModel):
|
||||
email: str
|
||||
role: str
|
||||
collection_id: str | None = None
|
||||
|
||||
|
||||
def _project_name(project_id: str) -> str | None:
|
||||
row = db.conn().execute(
|
||||
"SELECT name FROM projects WHERE id = ?", (project_id,)
|
||||
).fetchone()
|
||||
return row["name"] if row and row["name"] else None
|
||||
|
||||
|
||||
def make_router() -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/api/projects/{project_id}/members")
|
||||
async def list_members(project_id: str, request: Request) -> dict[str, Any]:
|
||||
user = auth.require_contributor(request)
|
||||
auth.require_project_readable(user, project_id)
|
||||
# The full subtree listing is a project-Owner view; a collection-only
|
||||
# Owner manages membership through the collection-scoped POST/DELETE.
|
||||
if not auth.can_invite_at_project(user, project_id):
|
||||
raise HTTPException(403, "You may not manage membership in this project")
|
||||
return {"items": memberships_mod.list_for_project(project_id)}
|
||||
|
||||
@router.post("/api/projects/{project_id}/members")
|
||||
async def grant_member(
|
||||
project_id: str, body: GrantBody, request: Request
|
||||
) -> dict[str, Any]:
|
||||
user = auth.require_contributor(request)
|
||||
auth.require_project_readable(user, project_id)
|
||||
|
||||
role = (body.role or "").strip().lower()
|
||||
if role not in memberships_mod.VALID_ROLES:
|
||||
raise HTTPException(422, f"invalid role {body.role!r}")
|
||||
|
||||
cid = (body.collection_id or "").strip() or None
|
||||
if cid is not None:
|
||||
# Collection-scope grant — bounded by Owner reach over that collection.
|
||||
col = collections_mod.get_collection(cid)
|
||||
if col is None or col["project_id"] != project_id:
|
||||
raise HTTPException(404, "Not found")
|
||||
if not auth.can_invite_at_collection(user, cid):
|
||||
raise HTTPException(403, "You may not manage membership in this collection")
|
||||
scope_type, scope_id = "collection", cid
|
||||
else:
|
||||
# Project-scope grant — bounded by Owner reach over the project.
|
||||
if not auth.can_invite_at_project(user, project_id):
|
||||
raise HTTPException(403, "You may not manage membership in this project")
|
||||
scope_type, scope_id = "project", project_id
|
||||
|
||||
grantee = memberships_mod.user_by_email(body.email)
|
||||
if grantee is None:
|
||||
raise HTTPException(
|
||||
404,
|
||||
"No account with that email — the invitee must sign in to the "
|
||||
"deployment before they can be granted a role",
|
||||
)
|
||||
|
||||
memberships_mod.grant(
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
user_id=grantee["id"],
|
||||
role=role,
|
||||
granted_by=user.user_id,
|
||||
)
|
||||
|
||||
# §15 (C.2): name the project and role to the grantee.
|
||||
col_name = None
|
||||
if scope_type == "collection":
|
||||
col = collections_mod.get_collection(scope_id)
|
||||
col_name = (col.get("name") if col else None) or scope_id
|
||||
notify.notify_scope_role_granted(
|
||||
recipient_user_id=grantee["id"],
|
||||
granter_user_id=user.user_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
role=role,
|
||||
project_id=project_id,
|
||||
project_name=_project_name(project_id),
|
||||
collection_name=col_name,
|
||||
)
|
||||
|
||||
return {
|
||||
"scope_type": scope_type,
|
||||
"scope_id": scope_id,
|
||||
"user_id": grantee["id"],
|
||||
"role": role,
|
||||
"pending": grantee["permission_state"] != "granted",
|
||||
}
|
||||
|
||||
@router.delete("/api/projects/{project_id}/members/{user_id}")
|
||||
async def revoke_member(
|
||||
project_id: str, user_id: int, request: Request
|
||||
) -> dict[str, Any]:
|
||||
user = auth.require_contributor(request)
|
||||
auth.require_project_readable(user, project_id)
|
||||
cid = (request.query_params.get("collection_id") or "").strip() or None
|
||||
if cid is not None:
|
||||
col = collections_mod.get_collection(cid)
|
||||
if col is None or col["project_id"] != project_id:
|
||||
raise HTTPException(404, "Not found")
|
||||
if not auth.can_invite_at_collection(user, cid):
|
||||
raise HTTPException(403, "You may not manage membership in this collection")
|
||||
removed = memberships_mod.revoke(
|
||||
scope_type="collection", scope_id=cid, user_id=user_id
|
||||
)
|
||||
else:
|
||||
if not auth.can_invite_at_project(user, project_id):
|
||||
raise HTTPException(403, "You may not manage membership in this project")
|
||||
removed = memberships_mod.revoke(
|
||||
scope_type="project", scope_id=project_id, user_id=user_id
|
||||
)
|
||||
return {"removed": removed}
|
||||
|
||||
return router
|
||||
@@ -0,0 +1,210 @@
|
||||
"""§22.4a SLICE-4/5 — entry metadata edit endpoints.
|
||||
|
||||
`POST .../rfcs/<slug>/meta` writes schema-defined metadata to an entry's sidecar
|
||||
with a direct commit (D7: direct commit for authorized roles), validated against
|
||||
the collection's field schema at the write boundary (INV-4), lazy-migrating a
|
||||
legacy entry to a clean body-only `.md` on first edit. The Owner-gated
|
||||
`metadata.migrate_collection` operator endpoint also lives here (SLICE-4 carried
|
||||
work); SLICE-5's bulk endpoint will join it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from . import (auth, cache, collections as collections_mod,
|
||||
metadata as metadata_mod, metadata_schema,
|
||||
projects as projects_mod)
|
||||
from .bot import Bot
|
||||
from .config import Config
|
||||
from .gitea import Gitea, GiteaError
|
||||
|
||||
|
||||
class MetaEditBody(BaseModel):
|
||||
values: dict[str, Any]
|
||||
|
||||
|
||||
class BulkMetaBody(BaseModel):
|
||||
slugs: list[str]
|
||||
op: str
|
||||
field: str
|
||||
value: Any = None
|
||||
|
||||
|
||||
def _apply_op(entry: Any, op: str, field: str, value: Any) -> Any:
|
||||
"""Return the new value for `field` after applying `op` to `entry`.
|
||||
|
||||
`set` → `value`; `add`/`remove` operate on the entry's current tags-list
|
||||
value for `field` (the route restricts add/remove to tags-type fields).
|
||||
"""
|
||||
if op == "set":
|
||||
return value
|
||||
current = metadata_mod.metadata_dict(entry).get(field) or []
|
||||
if not isinstance(current, list):
|
||||
current = [current]
|
||||
if op == "add":
|
||||
return current if value in current else [*current, value]
|
||||
if op == "remove":
|
||||
return [x for x in current if x != value]
|
||||
return value # unreachable; op validated by the route
|
||||
|
||||
|
||||
def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
def _content_repo() -> tuple[str, str]:
|
||||
return config.gitea_org, (projects_mod.default_content_repo(config) or "")
|
||||
|
||||
def _md_path(collection_id: str, slug: str) -> str:
|
||||
sub = collections_mod.subfolder_of(collection_id) or ""
|
||||
rfcs_dir = f"{sub}/rfcs" if sub else "rfcs"
|
||||
return f"{rfcs_dir}/{slug}.md"
|
||||
|
||||
@router.post("/api/projects/{project_id}/collections/{collection_id}/rfcs/{slug}/meta")
|
||||
async def edit_meta(
|
||||
project_id: str, collection_id: str, slug: str,
|
||||
body: MetaEditBody, request: Request,
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
if collections_mod.project_of_collection(collection_id) != project_id:
|
||||
raise HTTPException(404, "Collection not in project")
|
||||
# INV-4: contributor+ on the collection (returns False for anonymous).
|
||||
if not auth.can_contribute_in_collection(viewer, collection_id):
|
||||
raise HTTPException(403, "Contributor access required to edit metadata")
|
||||
col = collections_mod.get_collection(collection_id)
|
||||
fields = (col or {}).get("fields") or {}
|
||||
if not fields:
|
||||
raise HTTPException(422, "Collection declares no editable fields")
|
||||
if not body.values:
|
||||
raise HTTPException(422, "Provide at least one field value")
|
||||
unknown = [k for k in body.values if k not in fields]
|
||||
if unknown:
|
||||
raise HTTPException(422, f"Unknown field(s): {', '.join(sorted(unknown))}")
|
||||
|
||||
org, repo = _content_repo()
|
||||
md_path = _md_path(collection_id, slug)
|
||||
st = await metadata_mod.read_entry_from_git(gitea, org, repo, md_path)
|
||||
if st is None:
|
||||
raise HTTPException(404, f"{md_path} not found")
|
||||
|
||||
# Validate the *raw* submitted values (INV-4): catch a type mismatch
|
||||
# before `apply_values` coerces it — e.g. a scalar handed to a `tags`
|
||||
# field would otherwise char-split into a valid-looking list.
|
||||
problems = metadata_schema.validate(body.values, fields)
|
||||
if problems:
|
||||
raise HTTPException(422, {"problems": [p.as_dict() for p in problems]})
|
||||
new_entry = metadata_mod.apply_values(st.entry, body.values)
|
||||
|
||||
files = metadata_mod.write_entry_files(md_path, new_entry, st)
|
||||
try:
|
||||
await bot.commit_entry_files(
|
||||
viewer.as_actor(), org=org, repo=repo, files=files,
|
||||
message=f"Edit metadata: {slug}", branch="main")
|
||||
except GiteaError as e:
|
||||
raise HTTPException(502, f"Gitea: {e.detail}")
|
||||
|
||||
await cache.refresh_meta_repo(config, gitea)
|
||||
return {
|
||||
"ok": True, "slug": slug,
|
||||
"meta": metadata_mod.metadata_dict(new_entry),
|
||||
}
|
||||
|
||||
@router.post("/api/projects/{project_id}/collections/{collection_id}/meta/bulk")
|
||||
async def bulk_meta(
|
||||
project_id: str, collection_id: str,
|
||||
body: BulkMetaBody, request: Request,
|
||||
) -> dict[str, Any]:
|
||||
"""§22.4a PUC-2 (SLICE-5): apply one field op to many entries at once.
|
||||
|
||||
`set` works for any field; `add`/`remove` operate on a tags-type field.
|
||||
Each passing entry's metadata is validated at the write boundary
|
||||
(INV-4) and its sidecar staged; all stage into **one** commit (D7:
|
||||
bulk = 1 commit, reusing the SLICE-4 sidecar write-through). Entries
|
||||
that are missing or fail validation are reported in `rejected`; the
|
||||
rest in `applied`. A no-op (value unchanged) is applied without writing.
|
||||
"""
|
||||
viewer = auth.current_user(request)
|
||||
if collections_mod.project_of_collection(collection_id) != project_id:
|
||||
raise HTTPException(404, "Collection not in project")
|
||||
# INV-4: contributor+ on the collection (returns False for anonymous).
|
||||
if not auth.can_contribute_in_collection(viewer, collection_id):
|
||||
raise HTTPException(403, "Contributor access required to edit metadata")
|
||||
col = collections_mod.get_collection(collection_id)
|
||||
fields = (col or {}).get("fields") or {}
|
||||
if not fields:
|
||||
raise HTTPException(422, "Collection declares no editable fields")
|
||||
if not body.slugs:
|
||||
raise HTTPException(422, "Provide at least one entry")
|
||||
if body.op not in ("set", "add", "remove"):
|
||||
raise HTTPException(422, f"Unknown op: {body.op}")
|
||||
if body.field not in fields:
|
||||
raise HTTPException(422, f"Unknown field: {body.field}")
|
||||
if body.op in ("add", "remove") and fields[body.field].get("type") != "tags":
|
||||
raise HTTPException(422, f"op {body.op} requires a tags field")
|
||||
|
||||
org, repo = _content_repo()
|
||||
applied: list[str] = []
|
||||
rejected: list[dict[str, str]] = []
|
||||
all_ops: list[dict[str, Any]] = []
|
||||
for slug in body.slugs:
|
||||
md_path = _md_path(collection_id, slug)
|
||||
st = await metadata_mod.read_entry_from_git(gitea, org, repo, md_path)
|
||||
if st is None:
|
||||
rejected.append({"slug": slug, "reason": "not found"})
|
||||
continue
|
||||
new_value = _apply_op(st.entry, body.op, body.field, body.value)
|
||||
# Validate the *raw* new value before coercion (see edit_meta) so a
|
||||
# scalar `set` onto a tags field is rejected, not char-split.
|
||||
problems = metadata_schema.validate({body.field: new_value}, fields)
|
||||
if problems:
|
||||
rejected.append({"slug": slug,
|
||||
"reason": "; ".join(p.message for p in problems)})
|
||||
continue
|
||||
applied.append(slug)
|
||||
new_entry = metadata_mod.apply_values(st.entry, {body.field: new_value})
|
||||
if metadata_mod.metadata_dict(new_entry) != metadata_mod.metadata_dict(st.entry):
|
||||
all_ops.extend(metadata_mod.write_entry_files(md_path, new_entry, st))
|
||||
|
||||
committed = False
|
||||
if all_ops:
|
||||
n = len(applied)
|
||||
msg = f"Bulk {body.op} {body.field}: {n} entr{'y' if n == 1 else 'ies'}"
|
||||
try:
|
||||
await bot.commit_entry_files(
|
||||
viewer.as_actor(), org=org, repo=repo, files=all_ops,
|
||||
message=msg, branch="main")
|
||||
except GiteaError as e:
|
||||
raise HTTPException(502, f"Gitea: {e.detail}")
|
||||
committed = True
|
||||
await cache.refresh_meta_repo(config, gitea)
|
||||
return {"ok": True, "applied": applied,
|
||||
"rejected": rejected, "committed": committed}
|
||||
|
||||
@router.post("/api/projects/{project_id}/collections/{collection_id}/migrate")
|
||||
async def migrate(
|
||||
project_id: str, collection_id: str, request: Request
|
||||
) -> dict[str, Any]:
|
||||
"""§22.4a PUC-5: migrate a collection's legacy-frontmatter entries to
|
||||
clean body-only `.md` + sidecars, one commit per collection. Owner-gated
|
||||
operator action. Safe to ship now that every entry write path is
|
||||
sidecar-aware (SLICE-4 carried work). Idempotent."""
|
||||
viewer = auth.current_user(request)
|
||||
if collections_mod.project_of_collection(collection_id) != project_id:
|
||||
raise HTTPException(404, "Collection not in project")
|
||||
if not auth.is_collection_superuser(viewer, collection_id):
|
||||
raise HTTPException(403, "Owner access required to migrate a collection")
|
||||
org, repo = _content_repo()
|
||||
subfolder = collections_mod.subfolder_of(collection_id) or ""
|
||||
try:
|
||||
result = await metadata_mod.migrate_collection(
|
||||
gitea, org=org, repo=repo, subfolder=subfolder,
|
||||
actor=viewer.as_actor())
|
||||
except GiteaError as e:
|
||||
raise HTTPException(502, f"Gitea: {e.detail}")
|
||||
if result["committed"]:
|
||||
await cache.refresh_meta_repo(config, gitea)
|
||||
return result
|
||||
|
||||
return router
|
||||
+14
-9
@@ -23,7 +23,7 @@ from typing import Any
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, models_resolver, projects as projects_mod, rfc_links
|
||||
from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, metadata as metadata_mod, models_resolver, projects as projects_mod, rfc_links
|
||||
from .bot import Bot
|
||||
from .config import Config
|
||||
from .gitea import Gitea, GiteaError
|
||||
@@ -1009,20 +1009,25 @@ async def _replay_changes(
|
||||
|
||||
|
||||
def _extract_body_for_replay(is_super_draft: bool, content: str) -> str:
|
||||
# §22.4a SLICE-4: a meta-resident entry may be legacy (frontmatter+body) or
|
||||
# migrated (body-only). strip_frontmatter handles both without raising.
|
||||
if not is_super_draft:
|
||||
return content
|
||||
try:
|
||||
return entry_mod.parse(content).body
|
||||
except Exception:
|
||||
return content
|
||||
return metadata_mod.strip_frontmatter(content)
|
||||
|
||||
|
||||
def _wrap_body_for_replay(is_super_draft: bool, prior_content: str, new_body: str) -> str:
|
||||
# §22.4a SLICE-4: identity for body-only (migrated) files — never re-grow
|
||||
# frontmatter; preserve a legacy file's frontmatter until its next metadata
|
||||
# edit migrates it.
|
||||
nb = new_body if new_body.endswith("\n") else new_body + "\n"
|
||||
if not is_super_draft:
|
||||
return new_body
|
||||
entry = entry_mod.parse(prior_content)
|
||||
entry.body = new_body if new_body.endswith("\n") else new_body + "\n"
|
||||
return entry_mod.serialize(entry)
|
||||
return nb
|
||||
if entry_mod.FRONTMATTER_RE.match(prior_content):
|
||||
entry = entry_mod.parse(prior_content)
|
||||
entry.body = nb
|
||||
return entry_mod.serialize(entry)
|
||||
return nb
|
||||
|
||||
|
||||
def _resolution_branch_name(original_branch: str) -> str:
|
||||
|
||||
@@ -574,6 +574,42 @@ def effective_scope_role(user: SessionUser | None, collection_id: str) -> str |
|
||||
return row["role"] if row else None
|
||||
|
||||
|
||||
def _effective_project_role(user: SessionUser | None, project_id: str) -> str | None:
|
||||
"""The most-permissive role the user holds *over a project* — folding the
|
||||
global tier (deployment owner/admin, or a `scope_type='global'` grant) and a
|
||||
`scope_type='project'` grant on this project. Unlike `effective_scope_role`
|
||||
(which keys on a collection), this answers the project grain directly, for the
|
||||
§22.8 request-to-join membership check. Subject to the §6 admission floor."""
|
||||
if user is None or user.permission_state != "granted":
|
||||
return None
|
||||
if user.role in _DEPLOYMENT_SUPERUSER_ROLES:
|
||||
return "owner"
|
||||
row = db.conn().execute(
|
||||
"SELECT role FROM memberships "
|
||||
"WHERE user_id = ? AND ("
|
||||
" scope_type = 'global'"
|
||||
" OR (scope_type = 'project' AND scope_id = ?)) "
|
||||
"ORDER BY CASE role WHEN 'owner' THEN 0 ELSE 1 END LIMIT 1",
|
||||
(user.user_id, project_id),
|
||||
).fetchone()
|
||||
return row["role"] if row else None
|
||||
|
||||
|
||||
def effective_role_at_scope(
|
||||
user: SessionUser | None, scope_type: str, scope_id: str
|
||||
) -> str | None:
|
||||
"""The most-permissive scope role the user holds over a `(scope_type,
|
||||
scope_id)` target — the scope-grain twin of `effective_scope_role`. A
|
||||
`collection` target folds global → project → collection (the existing
|
||||
resolver); a `project` target folds global → project. Returns None when no
|
||||
grant reaches the scope. Drives the §22.8 "already a member?" gate."""
|
||||
if scope_type == "collection":
|
||||
return effective_scope_role(user, scope_id)
|
||||
if scope_type == "project":
|
||||
return _effective_project_role(user, scope_id)
|
||||
return None
|
||||
|
||||
|
||||
def collection_visibility(collection_id: str) -> str:
|
||||
"""The collection's own §22.5 visibility. A missing row reads as 'gated' —
|
||||
an unknown collection is invisible rather than open."""
|
||||
@@ -682,6 +718,45 @@ def can_create_collection(user: SessionUser | None, project_id: str) -> bool:
|
||||
return row is not None
|
||||
|
||||
|
||||
def can_create_project(user: SessionUser | None) -> bool:
|
||||
"""§22 S5 (§A.2 / §B.1): may the user create a new project? "+ New project"
|
||||
is a **global-Owner** action — a deployment owner/admin (a global Owner per
|
||||
§B.1) or a holder of an explicit `scope_type='global'` Owner grant. Creating
|
||||
a project is deployment-level, so it is not reachable by a project- or
|
||||
collection-scope grant nor by a global RFC Contributor (that role creates
|
||||
collections, not projects). Subject to the §6 admission floor."""
|
||||
if user is None or user.permission_state != "granted":
|
||||
return False
|
||||
if user.role in _DEPLOYMENT_SUPERUSER_ROLES:
|
||||
return True
|
||||
row = db.conn().execute(
|
||||
"SELECT 1 FROM memberships "
|
||||
"WHERE user_id = ? AND scope_type = 'global' AND role = 'owner' LIMIT 1",
|
||||
(user.user_id,),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def can_invite_at_project(user: SessionUser | None, project_id: str) -> bool:
|
||||
"""§22 S4 (C.2): may the user grant scope roles at this project (or at any
|
||||
collection within it)? Managing membership is an *Owner* capability whose
|
||||
reach covers the project — a deployment owner/admin, a global Owner, or this
|
||||
project's Owner. An RFC Contributor does not manage membership (C.2.4); a
|
||||
collection Owner's reach is its own collection only (C.2.3), so it is not
|
||||
offered project-scope invites. Identical to `is_project_superuser` — the
|
||||
invite gate IS "is an Owner over this project"."""
|
||||
return is_project_superuser(user, project_id)
|
||||
|
||||
|
||||
def can_invite_at_collection(user: SessionUser | None, collection_id: str) -> bool:
|
||||
"""§22 S4 (C.2): may the user grant scope roles at this collection? An Owner
|
||||
whose reach covers it — the collection's Owner, its project's Owner, a global
|
||||
Owner, or a deployment owner/admin (`is_collection_superuser`). This is the
|
||||
narrowest invite reach; a collection Owner who is nothing more may invite
|
||||
here but not at the project or globally (C.2.3)."""
|
||||
return is_collection_superuser(user, collection_id)
|
||||
|
||||
|
||||
# v0.16.0 (roadmap item #12): per-RFC membership helpers.
|
||||
#
|
||||
# These don't replace `require_contributor` — they layer on top of it for
|
||||
|
||||
+162
-78
@@ -27,7 +27,7 @@ import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from . import db, entry as entry_mod, notify
|
||||
from . import db, entry as entry_mod, metadata as metadata_mod, notify
|
||||
from .gitea import Gitea, GiteaError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -198,6 +198,82 @@ class Bot:
|
||||
)
|
||||
return created
|
||||
|
||||
async def create_project(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
org: str,
|
||||
registry_repo: str,
|
||||
content_repo: str,
|
||||
project_id: str,
|
||||
projects_yaml_new: str,
|
||||
projects_yaml_sha: str,
|
||||
readme_text: str,
|
||||
) -> dict:
|
||||
"""§22 S5 (§A.2): stand up a new project. A global-Owner action wrapping
|
||||
a bot write at two git sources:
|
||||
|
||||
1. **provision the content repo** — create `org/content_repo` if it
|
||||
doesn't exist, then seed a `README.md` on `main` so the branch
|
||||
exists (the contents API initialises the repo with that commit; the
|
||||
corpus mirror and the propose path both need a `main` to write to).
|
||||
2. **register the project** — commit the caller-composed
|
||||
`projects.yaml` (the existing doc with the new project appended) to
|
||||
the registry repo's `main`.
|
||||
|
||||
Like `create_collection`, this is a structural admin action committed
|
||||
straight to main (no PR), like the registry config it feeds; the caller
|
||||
then re-runs the registry mirror so the new `projects` + default
|
||||
`collections` rows flow from the registry (§22.2 keeps the registry the
|
||||
source of truth). Logs a `create_project` audit row for the §6.5 trail.
|
||||
Returns the registry update_file result (carries the new commit sha)."""
|
||||
ae = actor.email or f"{actor.gitea_login}@users.noreply"
|
||||
existing = await self._gitea.get_repo(org, content_repo)
|
||||
if existing is None:
|
||||
await self._gitea.create_org_repo(
|
||||
org, content_repo, description=f"Content repo for project {project_id}"
|
||||
)
|
||||
# Seed a README if absent, which also establishes `main` on a freshly
|
||||
# created (auto_init=False) repo — the contents API initialises the repo
|
||||
# with that commit. Keyed on the README rather than the branch so it is
|
||||
# idempotent and behaves identically whether the repo has a bare `main`
|
||||
# or no branch at all. Mirrors `ensure_rfc_repo_seed`'s empty-repo seed.
|
||||
readme = await self._gitea.get_contents(org, content_repo, "README.md", ref="main")
|
||||
if readme is None:
|
||||
await self._gitea.create_file(
|
||||
org,
|
||||
content_repo,
|
||||
"README.md",
|
||||
content=readme_text,
|
||||
message=_stamp_single(f"chore: initialise content repo for {project_id}", actor),
|
||||
branch="main",
|
||||
author_name=actor.display_name,
|
||||
author_email=ae,
|
||||
)
|
||||
result = await self._gitea.update_file(
|
||||
org,
|
||||
registry_repo,
|
||||
"projects.yaml",
|
||||
content=projects_yaml_new,
|
||||
sha=projects_yaml_sha,
|
||||
message=_stamp_single(f"chore: create project {project_id}", actor),
|
||||
branch="main",
|
||||
author_name=actor.display_name,
|
||||
author_email=ae,
|
||||
)
|
||||
commit_sha = (
|
||||
result.get("commit", {}).get("sha")
|
||||
or result.get("content", {}).get("sha")
|
||||
or ""
|
||||
)
|
||||
_log(
|
||||
actor,
|
||||
"create_project",
|
||||
bot_commit_sha=commit_sha,
|
||||
details={"project_id": project_id, "content_repo": content_repo},
|
||||
)
|
||||
return result
|
||||
|
||||
# ----- Meta repo: idea PRs (§9.1 / §9.2) -----
|
||||
|
||||
async def open_idea_pr(
|
||||
@@ -328,6 +404,43 @@ class Bot:
|
||||
pr_number=pr_number,
|
||||
)
|
||||
|
||||
# ----- Entry sidecar writes (§22.4a SLICE-4) -----
|
||||
|
||||
async def commit_entry_files(
|
||||
self, actor: Actor, *, org: str, repo: str,
|
||||
files: list[dict], message: str, branch: str = "main",
|
||||
) -> dict:
|
||||
"""Commit a set of entry file ops (sidecar + body-only `.md`, from
|
||||
`metadata.write_entry_files`) in one commit. Used by the direct-commit
|
||||
metadata paths and, on a branch, by `open_entry_pr`."""
|
||||
return await self._gitea.change_files(
|
||||
org, repo, files=files,
|
||||
message=_stamp_single(message, actor), branch=branch,
|
||||
author_name=actor.display_name,
|
||||
author_email=actor.email or f"{actor.gitea_login}@users.noreply",
|
||||
)
|
||||
|
||||
async def open_entry_pr(
|
||||
self, actor: Actor, *, org: str, repo: str, slug: str,
|
||||
files: list[dict], pr_title: str, pr_description: str,
|
||||
branch_prefix: str = "metadata",
|
||||
) -> dict:
|
||||
"""Create a branch, commit entry file ops there, and open a PR — the
|
||||
sidecar-aware successor to `open_metadata_pr`'s single-file write."""
|
||||
import secrets
|
||||
|
||||
branch = f"{branch_prefix}-{slug}-{secrets.token_hex(3)}"
|
||||
await self._gitea.create_branch(org, repo, branch, from_branch="main")
|
||||
await self.commit_entry_files(
|
||||
actor, org=org, repo=repo, files=files,
|
||||
message=pr_title, branch=branch)
|
||||
_subject, pr_body = _stamp("", pr_description, actor)
|
||||
pr = await self._gitea.create_pull(
|
||||
org, repo, title=pr_title, body=pr_body, head=branch, base="main")
|
||||
_log(actor, "open_entry_pr", rfc_slug=slug, branch_name=branch,
|
||||
pr_number=pr["number"], details={"pr_title": pr_title})
|
||||
return pr
|
||||
|
||||
# ----- Meta repo: metadata-pane PRs (§9.5) -----
|
||||
|
||||
async def open_metadata_pr(
|
||||
@@ -743,35 +856,29 @@ class Bot:
|
||||
org: str,
|
||||
meta_repo: str,
|
||||
slug: str,
|
||||
new_file_contents: str,
|
||||
prior_sha: str,
|
||||
files: list[dict],
|
||||
rfc_id: str | None,
|
||||
owners: list[str],
|
||||
) -> dict:
|
||||
"""§13.3 (meta-only): open a PR against the meta repo that flips the
|
||||
entry's frontmatter to `state: active` with the graduation stamps
|
||||
and — **optionally** — the integer `id`, **keeping the body
|
||||
unchanged** (§1 meta-only topology; no repo is created and no body
|
||||
is stripped). When `rfc_id` is None the entry graduates without a
|
||||
number (id stays null, slug is canonical per §2.3, §13.2). Branch
|
||||
name uses the `graduate-<slug>-<6hex>` shape — dash-separated like
|
||||
the other meta-repo branches per the §19.2 path-routing candidate.
|
||||
entry to `state: active` with the graduation stamps and — **optionally**
|
||||
— the integer `id`, **keeping the body unchanged** (§1 meta-only
|
||||
topology; no repo is created and no body is stripped). The graduation
|
||||
metadata is written to the entry's sidecar (§22.4a) via `files`; a legacy
|
||||
`.md` is lazy-migrated to body-only in the same commit. When `rfc_id` is
|
||||
None the entry graduates without a number (id stays null, slug is
|
||||
canonical per §2.3, §13.2). Branch name uses the `graduate-<slug>-<6hex>`
|
||||
shape — dash-separated like the other meta-repo branches per the §19.2
|
||||
path-routing candidate.
|
||||
"""
|
||||
import secrets
|
||||
|
||||
branch = f"graduate-{slug}-{secrets.token_hex(3)}"
|
||||
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
|
||||
ae = actor.email or f"{actor.gitea_login}@users.noreply"
|
||||
commit_subject = f"Graduate {slug} → {rfc_id}" if rfc_id else f"Graduate {slug} (no number)"
|
||||
commit_message = _stamp_single(commit_subject, actor)
|
||||
result = await self._gitea.update_file(
|
||||
org, meta_repo, f"rfcs/{slug}.md",
|
||||
content=new_file_contents,
|
||||
sha=prior_sha,
|
||||
message=commit_message,
|
||||
branch=branch,
|
||||
author_name=actor.display_name, author_email=ae,
|
||||
)
|
||||
result = await self.commit_entry_files(
|
||||
actor, org=org, repo=meta_repo, files=files,
|
||||
message=commit_subject, branch=branch)
|
||||
commit_sha = (
|
||||
result.get("commit", {}).get("sha")
|
||||
or result.get("content", {}).get("sha")
|
||||
@@ -860,35 +967,26 @@ class Bot:
|
||||
org: str,
|
||||
meta_repo: str,
|
||||
slug: str,
|
||||
new_file_contents: str,
|
||||
prior_sha: str,
|
||||
files: list[dict],
|
||||
verb: str,
|
||||
target_state: str,
|
||||
) -> dict:
|
||||
"""§13.7: open a PR flipping `rfcs/<slug>.md` to `state:
|
||||
<target_state>` — for retire (`verb='retire'`, target `retired`)
|
||||
or un-retire (`verb='unretire'`, target the restored prior state).
|
||||
Only the frontmatter `state` changes; the body and every other
|
||||
field (including the integer `id`) are kept, so an un-retire
|
||||
restores the entry exactly. Branch shape mirrors graduation's
|
||||
`<verb>-<slug>-<6hex>`.
|
||||
"""§13.7: open a PR flipping an entry to `state: <target_state>` — for
|
||||
retire (`verb='retire'`, target `retired`) or un-retire
|
||||
(`verb='unretire'`, target the restored prior state). The `state` change
|
||||
is written to the entry's metadata sidecar (§22.4a), keeping the `.md`
|
||||
body and every other field, so an un-retire restores the entry exactly.
|
||||
`files` come from `metadata.write_entry_files`. Branch shape mirrors
|
||||
graduation's `<verb>-<slug>-<6hex>`.
|
||||
"""
|
||||
import secrets
|
||||
|
||||
branch = f"{verb}-{slug}-{secrets.token_hex(3)}"
|
||||
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
|
||||
ae = actor.email or f"{actor.gitea_login}@users.noreply"
|
||||
verb_title = "Retire" if verb == "retire" else "Un-retire"
|
||||
commit_subject = f"{verb_title} {slug}"
|
||||
commit_message = _stamp_single(commit_subject, actor)
|
||||
result = await self._gitea.update_file(
|
||||
org, meta_repo, f"rfcs/{slug}.md",
|
||||
content=new_file_contents,
|
||||
sha=prior_sha,
|
||||
message=commit_message,
|
||||
branch=branch,
|
||||
author_name=actor.display_name, author_email=ae,
|
||||
)
|
||||
result = await self.commit_entry_files(
|
||||
actor, org=org, repo=meta_repo, files=files,
|
||||
message=f"{verb_title} {slug}", branch=branch)
|
||||
commit_sha = (
|
||||
result.get("commit", {}).get("sha")
|
||||
or result.get("content", {}).get("sha")
|
||||
@@ -1058,30 +1156,23 @@ class Bot:
|
||||
org: str,
|
||||
meta_repo: str,
|
||||
slug: str,
|
||||
new_file_contents: str,
|
||||
prior_sha: str,
|
||||
files: list[dict],
|
||||
) -> dict:
|
||||
"""§13.1: open a PR adding the actor to the entry's `owners:` list.
|
||||
|
||||
Touches only the frontmatter of `rfcs/<slug>.md`. Branch shape is
|
||||
`claim/<slug>` — single attempt per super-draft per actor (Gitea
|
||||
refuses duplicate branch creation, which is the right behavior:
|
||||
if the claim is still open, point the contributor at the existing
|
||||
PR rather than opening a second one).
|
||||
Writes the updated `owners:` to the entry's metadata sidecar (§22.4a)
|
||||
via `files`; a legacy `.md` is lazy-migrated to body-only in the same
|
||||
commit. Branch shape is `claim/<slug>` — single attempt per super-draft
|
||||
per actor (Gitea refuses duplicate branch creation, which is the right
|
||||
behavior: if the claim is still open, point the contributor at the
|
||||
existing PR rather than opening a second one).
|
||||
"""
|
||||
branch = f"claim/{slug}"
|
||||
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
|
||||
ae = actor.email or f"{actor.gitea_login}@users.noreply"
|
||||
commit_subject = f"Claim ownership of {slug} for {actor.gitea_login}"
|
||||
commit_message = _stamp_single(commit_subject, actor)
|
||||
result = await self._gitea.update_file(
|
||||
org, meta_repo, f"rfcs/{slug}.md",
|
||||
content=new_file_contents,
|
||||
sha=prior_sha,
|
||||
message=commit_message,
|
||||
branch=branch,
|
||||
author_name=actor.display_name, author_email=ae,
|
||||
)
|
||||
result = await self.commit_entry_files(
|
||||
actor, org=org, repo=meta_repo, files=files,
|
||||
message=commit_subject, branch=branch)
|
||||
commit_sha = (
|
||||
result.get("commit", {}).get("sha")
|
||||
or result.get("content", {}).get("sha")
|
||||
@@ -1120,29 +1211,22 @@ class Bot:
|
||||
reviewed_by: str,
|
||||
reviewed_at: str,
|
||||
) -> None:
|
||||
"""Clear §22.4c unreviewed on an active entry by rewriting its
|
||||
frontmatter on main. Stamps the commit with the §6.5 On-behalf-of
|
||||
trailer and writes an actions-log row, mirroring the graduation
|
||||
stamp's bot-write shape."""
|
||||
"""Clear §22.4c unreviewed on an active entry by writing its metadata
|
||||
sidecar on main (§22.4a). Dual-reads the entry (so a migrated body-only
|
||||
`.md` doesn't crash) and lazy-migrates a legacy `.md` to body-only in the
|
||||
same commit. Stamps the §6.5 On-behalf-of trailer and writes an
|
||||
actions-log row, mirroring the graduation stamp's bot-write shape."""
|
||||
path = f"rfcs/{slug}.md"
|
||||
result = await self._gitea.read_file(org, meta_repo, path, ref="main")
|
||||
if result is None:
|
||||
st = await metadata_mod.read_entry_from_git(self._gitea, org, meta_repo, path)
|
||||
if st is None:
|
||||
raise GiteaError(404, f"{path} not found")
|
||||
text, sha = result
|
||||
e = entry_mod.parse(text)
|
||||
e.unreviewed = False
|
||||
e.reviewed_at = reviewed_at
|
||||
e.reviewed_by = reviewed_by
|
||||
commit_message = _stamp_single(f"Mark {slug} reviewed", actor)
|
||||
result = await self._gitea.update_file(
|
||||
org, meta_repo, path,
|
||||
content=entry_mod.serialize(e),
|
||||
sha=sha,
|
||||
message=commit_message,
|
||||
branch="main",
|
||||
author_name=actor.display_name,
|
||||
author_email=actor.email or f"{actor.gitea_login}@users.noreply",
|
||||
)
|
||||
e = metadata_mod.apply_values(st.entry, {
|
||||
"unreviewed": False, "reviewed_at": reviewed_at, "reviewed_by": reviewed_by,
|
||||
})
|
||||
files = metadata_mod.write_entry_files(path, e, st)
|
||||
result = await self.commit_entry_files(
|
||||
actor, org=org, repo=meta_repo, files=files,
|
||||
message=f"Mark {slug} reviewed", branch="main")
|
||||
commit_sha = (
|
||||
result.get("commit", {}).get("sha")
|
||||
or result.get("content", {}).get("sha")
|
||||
|
||||
+65
-6
@@ -27,7 +27,15 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
from . import db, entry as entry_mod, projects as projects_mod, registry as registry_mod
|
||||
from . import (
|
||||
collections as collections_mod,
|
||||
db,
|
||||
entry as entry_mod,
|
||||
metadata as metadata_mod,
|
||||
metadata_schema,
|
||||
projects as projects_mod,
|
||||
registry as registry_mod,
|
||||
)
|
||||
from .config import Config
|
||||
from .gitea import Gitea, GiteaError
|
||||
|
||||
@@ -77,6 +85,22 @@ async def _refresh_collection_corpus(
|
||||
project_id, collection_id, rfcs_dir, e)
|
||||
return
|
||||
|
||||
# §22.4a SLICE-2: a collection may declare a metadata field schema. Fetch it
|
||||
# once for the whole corpus pass; entries whose stored values fail it are
|
||||
# flagged malformed advisory-only (INV-3) — the read never hard-fails. A
|
||||
# collection with no schema validates nothing (INV-5, the default unchanged).
|
||||
col = collections_mod.get_collection(collection_id)
|
||||
fields_schema = (col or {}).get("fields") or None
|
||||
|
||||
# §22.4a SLICE-1: an entry's metadata may live in a `<slug>.meta.yaml`
|
||||
# sidecar (the source of truth) with the `.md` kept as pure prose. Map the
|
||||
# sidecars surfaced by this listing so each `.md` can dual-read its sibling.
|
||||
sidecar_path_by_slug = {
|
||||
metadata_mod.slug_of_sidecar(f["name"]): f["path"]
|
||||
for f in files
|
||||
if f.get("type") == "file" and metadata_mod.is_sidecar(f.get("name", ""))
|
||||
}
|
||||
|
||||
seen_slugs: set[str] = set()
|
||||
for f in files:
|
||||
if f.get("type") != "file" or not f.get("name", "").endswith(".md"):
|
||||
@@ -85,8 +109,14 @@ async def _refresh_collection_corpus(
|
||||
if not result:
|
||||
continue
|
||||
text, sha = result
|
||||
stem = f["name"][:-len(".md")]
|
||||
sidecar_text: str | None = None
|
||||
sidecar_path = sidecar_path_by_slug.get(stem)
|
||||
if sidecar_path:
|
||||
sc_result = await gitea.read_file(org, repo, sidecar_path, ref="main")
|
||||
sidecar_text = sc_result[0] if sc_result else None
|
||||
try:
|
||||
entry = entry_mod.parse(text)
|
||||
entry, malformed = metadata_mod.read_entry(text, sidecar_text, fallback_slug=stem)
|
||||
except Exception as parse_err:
|
||||
log.warning("refresh_meta_repo: %s/%s: skipping %s: %s",
|
||||
project_id, collection_id, f["path"], parse_err)
|
||||
@@ -95,8 +125,24 @@ async def _refresh_collection_corpus(
|
||||
log.warning("refresh_meta_repo: %s/%s: skipping %s: missing slug",
|
||||
project_id, collection_id, f["path"])
|
||||
continue
|
||||
if malformed:
|
||||
log.warning("refresh_meta_repo: %s/%s: %s has malformed metadata sidecar",
|
||||
project_id, collection_id, f["path"])
|
||||
# §22.4a SLICE-2: advisory schema validation (INV-3). A schema violation
|
||||
# flags the entry malformed without blocking the read, OR-ed onto any
|
||||
# sidecar-syntax malformation above.
|
||||
if fields_schema:
|
||||
problems = metadata_schema.validate(
|
||||
metadata_mod.metadata_dict(entry), fields_schema
|
||||
)
|
||||
if problems:
|
||||
malformed = True
|
||||
log.warning("refresh_meta_repo: %s/%s: %s fails its field schema: %s",
|
||||
project_id, collection_id, f["path"],
|
||||
"; ".join(p.message for p in problems))
|
||||
seen_slugs.add(entry.slug)
|
||||
_upsert_cached_rfc(entry, body_sha=sha, collection_id=collection_id)
|
||||
_upsert_cached_rfc(entry, body_sha=sha, collection_id=collection_id,
|
||||
metadata_malformed=malformed)
|
||||
|
||||
# Entries removed from a collection's rfcs/ — the spec keeps withdrawn entries
|
||||
# as historical record (§3), so this fires only for out-of-band deletes;
|
||||
@@ -112,13 +158,22 @@ async def _refresh_collection_corpus(
|
||||
project_id, collection_id, missing)
|
||||
|
||||
|
||||
def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str, collection_id: str = "default") -> None:
|
||||
def _upsert_cached_rfc(
|
||||
entry: entry_mod.Entry,
|
||||
body_sha: str,
|
||||
collection_id: str = "default",
|
||||
metadata_malformed: bool = False,
|
||||
) -> None:
|
||||
# §6.6: models_json stays NULL when the frontmatter key is absent
|
||||
# (inherit operator universe) and '[]' for the explicit opt-out.
|
||||
models_json = json.dumps(entry.models) if entry.models is not None else None
|
||||
# §6.7: funder_login mirrors the optional `funder:` frontmatter
|
||||
# field. NULL means absent — operator credentials are used.
|
||||
funder_login = entry.funder or None
|
||||
# §22.4a SLICE-3: persist the full per-entry metadata mapping (known keys +
|
||||
# extra, never the body) so facet/filter can read any declared field. Stored
|
||||
# via metadata_dict so the sidecar's forward-compat keys (INV-7) ride along.
|
||||
meta_json = json.dumps(metadata_mod.metadata_dict(entry))
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO cached_rfcs
|
||||
@@ -126,8 +181,8 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str, collection_id: str
|
||||
graduated_at, graduated_by, owners_json, arbiters_json, tags_json,
|
||||
models_json, funder_login, body, body_sha,
|
||||
unreviewed, reviewed_at, reviewed_by, collection_id,
|
||||
last_entry_commit_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
||||
metadata_malformed, meta_json, last_entry_commit_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
||||
ON CONFLICT(collection_id, slug) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
state = excluded.state,
|
||||
@@ -147,6 +202,8 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str, collection_id: str
|
||||
unreviewed = excluded.unreviewed,
|
||||
reviewed_at = excluded.reviewed_at,
|
||||
reviewed_by = excluded.reviewed_by,
|
||||
metadata_malformed = excluded.metadata_malformed,
|
||||
meta_json = excluded.meta_json,
|
||||
last_entry_commit_at = datetime('now'),
|
||||
updated_at = datetime('now')
|
||||
""",
|
||||
@@ -171,6 +228,8 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str, collection_id: str
|
||||
entry.reviewed_at,
|
||||
entry.reviewed_by,
|
||||
collection_id,
|
||||
1 if metadata_malformed else 0,
|
||||
meta_json,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -8,10 +8,57 @@ authz (auth.py) recovers a row's project by joining `collections` on
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from . import db
|
||||
|
||||
DEFAULT_COLLECTION_ID = "default"
|
||||
|
||||
# §22.4a item (2): the displayed noun for an entry is a type-driven label, a
|
||||
# framework concept (like role names), not deployment content. The chrome reads
|
||||
# this from the API rather than hardcoding "RFC", so a `specification` collection
|
||||
# says "Spec" and a `bdd` collection says "Feature" with no per-deployment config.
|
||||
ENTRY_NOUN = {
|
||||
"document": "RFC",
|
||||
"specification": "Spec",
|
||||
"bdd": "Feature",
|
||||
}
|
||||
_DEFAULT_ENTRY_NOUN = "RFC"
|
||||
|
||||
|
||||
def entry_noun(collection_type: str) -> str:
|
||||
"""The §22.4a entry noun for a collection type. Unknown types fall back to
|
||||
the generic 'RFC' so a future type is never label-less."""
|
||||
return ENTRY_NOUN.get(collection_type, _DEFAULT_ENTRY_NOUN)
|
||||
|
||||
|
||||
def _enabled_models_from_config(config_json: str | None) -> list[str] | None:
|
||||
"""§22.12 per-collection enabled_models from a `config_json` blob, or None
|
||||
when unset (the collection inherits its project's universe)."""
|
||||
if not config_json:
|
||||
return None
|
||||
try:
|
||||
cfg = json.loads(config_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
em = cfg.get("enabled_models") if isinstance(cfg, dict) else None
|
||||
return [str(m) for m in em] if isinstance(em, list) else None
|
||||
|
||||
|
||||
def _fields_from_config(config_json: str | None) -> dict | None:
|
||||
"""§22.4a SLICE-2 per-collection metadata field schema from a `config_json`
|
||||
blob, or None when the collection declares no `fields:`. The stored value is
|
||||
already normalized by `metadata_schema.parse_fields` at ingest, so it's
|
||||
served verbatim."""
|
||||
if not config_json:
|
||||
return None
|
||||
try:
|
||||
cfg = json.loads(config_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
fields = cfg.get("fields") if isinstance(cfg, dict) else None
|
||||
return fields if isinstance(fields, dict) and fields else None
|
||||
|
||||
|
||||
def default_collection_id(project_id: str) -> str:
|
||||
"""The id of a project's default (S1: sole) collection. Falls back to the
|
||||
@@ -60,13 +107,24 @@ def subfolder_of(collection_id: str) -> str:
|
||||
|
||||
|
||||
def get_collection(collection_id: str) -> dict | None:
|
||||
"""The full collection row as a dict, or None if unknown."""
|
||||
"""The full collection row as a dict, or None if unknown. `enabled_models`
|
||||
(§22.12) is unpacked from `config_json` as a list, or None when unset."""
|
||||
row = db.conn().execute(
|
||||
"SELECT id, project_id, type, subfolder, initial_state, visibility, name "
|
||||
"FROM collections WHERE id = ?",
|
||||
"SELECT id, project_id, type, subfolder, initial_state, visibility, name, "
|
||||
"config_json FROM collections WHERE id = ?",
|
||||
(collection_id,),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
if row is None:
|
||||
return None
|
||||
out = dict(row)
|
||||
config_json = out.pop("config_json", None)
|
||||
out["enabled_models"] = _enabled_models_from_config(config_json)
|
||||
# §22.4a SLICE-2: serve the collection's metadata field schema (None when
|
||||
# the collection declares no `fields:` — INV-5, the default `document`
|
||||
# collection is unaffected).
|
||||
out["fields"] = _fields_from_config(config_json)
|
||||
out["entry_noun"] = entry_noun(out["type"])
|
||||
return out
|
||||
|
||||
|
||||
def list_collections(project_id: str, include_unlisted: bool = False) -> list[dict]:
|
||||
@@ -82,5 +140,7 @@ def list_collections(project_id: str, include_unlisted: bool = False) -> list[di
|
||||
for r in rows:
|
||||
if not include_unlisted and r["visibility"] == "unlisted":
|
||||
continue
|
||||
out.append(dict(r))
|
||||
item = dict(r)
|
||||
item["entry_noun"] = entry_noun(item["type"])
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
+44
-2
@@ -58,6 +58,20 @@ class Entry:
|
||||
reviewed_at: str | None = None
|
||||
reviewed_by: str | None = None
|
||||
body: str = ""
|
||||
# §22.4a (configurable collection metadata, SLICE-1): frontmatter / sidecar
|
||||
# keys outside the known set above are preserved here verbatim so they ride
|
||||
# along untouched through a parse→serialize round-trip and the
|
||||
# frontmatter→sidecar migration (INV-7). Includes future collection-`fields:`
|
||||
# schema values, which the engine does not interpret.
|
||||
extra: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
# Frontmatter keys the Entry models explicitly; everything else is `extra`.
|
||||
KNOWN_KEYS = {
|
||||
"slug", "title", "state", "id", "repo", "proposed_by", "proposed_at",
|
||||
"graduated_at", "graduated_by", "owners", "arbiters", "tags", "models",
|
||||
"funder", "unreviewed", "reviewed_at", "reviewed_by",
|
||||
}
|
||||
|
||||
|
||||
def parse(text: str) -> Entry:
|
||||
@@ -66,6 +80,16 @@ def parse(text: str) -> Entry:
|
||||
raise ValueError("Entry file missing frontmatter")
|
||||
fm = yaml.safe_load(match.group(1)) or {}
|
||||
body = match.group(2).lstrip("\n")
|
||||
return from_frontmatter(fm, body)
|
||||
|
||||
|
||||
def from_frontmatter(fm: dict[str, Any], body: str = "") -> Entry:
|
||||
"""Build an Entry from an already-parsed metadata mapping + body.
|
||||
|
||||
Shared by `parse()` (legacy `.md` frontmatter) and the SLICE-1 dual-read
|
||||
sidecar path (`metadata.read_entry`), so both produce identical records
|
||||
(INV-6). `fm` keys outside `KNOWN_KEYS` are preserved on `Entry.extra`.
|
||||
"""
|
||||
raw_models = fm.get("models", _ABSENT)
|
||||
if raw_models is _ABSENT or raw_models is None:
|
||||
models: list[str] | None = None
|
||||
@@ -74,6 +98,7 @@ def parse(text: str) -> Entry:
|
||||
raw_funder = fm.get("funder")
|
||||
funder = str(raw_funder).strip() if raw_funder else None
|
||||
unreviewed = bool(fm.get("unreviewed") or False)
|
||||
extra = {k: v for k, v in fm.items() if k not in KNOWN_KEYS}
|
||||
return Entry(
|
||||
slug=str(fm.get("slug") or ""),
|
||||
title=str(fm.get("title") or ""),
|
||||
@@ -93,11 +118,18 @@ def parse(text: str) -> Entry:
|
||||
reviewed_at=fm.get("reviewed_at") or None,
|
||||
reviewed_by=fm.get("reviewed_by") or None,
|
||||
body=body,
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
|
||||
def serialize(entry: Entry) -> str:
|
||||
"""Emit canonical entry file text — frontmatter then body."""
|
||||
def to_frontmatter_dict(entry: Entry) -> dict[str, Any]:
|
||||
"""The canonical ordered metadata mapping for an entry.
|
||||
|
||||
Shared by `serialize()` (which wraps it in `---` fences over the body) and
|
||||
the SLICE-1 sidecar writer (`metadata.sidecar_yaml`, which emits the same
|
||||
mapping as a standalone `<slug>.meta.yaml`). Known keys first in canonical
|
||||
order, then `extra` (INV-7).
|
||||
"""
|
||||
fm: dict[str, Any] = {
|
||||
"slug": entry.slug,
|
||||
"title": entry.title,
|
||||
@@ -129,6 +161,16 @@ def serialize(entry: Entry) -> str:
|
||||
fm["reviewed_at"] = entry.reviewed_at
|
||||
if entry.reviewed_by:
|
||||
fm["reviewed_by"] = entry.reviewed_by
|
||||
# INV-7: forward-compat / unknown keys ride along after the known ones.
|
||||
for k, v in entry.extra.items():
|
||||
if k not in fm:
|
||||
fm[k] = v
|
||||
return fm
|
||||
|
||||
|
||||
def serialize(entry: Entry) -> str:
|
||||
"""Emit canonical entry file text — frontmatter then body."""
|
||||
fm = to_frontmatter_dict(entry)
|
||||
yaml_text = yaml.safe_dump(fm, sort_keys=False, default_flow_style=False).rstrip()
|
||||
body = entry.body.lstrip("\n")
|
||||
if body:
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""§22.4a SLICE-3 — faceted catalog filtering + counts (read).
|
||||
|
||||
Pure functions over already-mirrored entries; no I/O, no DB. An "entry" here is
|
||||
a plain dict carrying at least:
|
||||
- "state": the lifecycle state column,
|
||||
- "metadata_malformed": bool,
|
||||
- "meta": the per-entry metadata mapping (from cached_rfcs.meta_json).
|
||||
|
||||
Facetable fields (§5.1, plan decision 1): a collection's declared `enum` and
|
||||
`tags` fields, in declaration order, plus the built-in `state` facet appended
|
||||
last — but only when the collection declares a schema (INV-5: a no-`fields:`
|
||||
collection has no facets at all, so the frontend keeps its legacy chips). `text`
|
||||
fields are not faceted in v1 (they get a detail control in SLICE-4).
|
||||
|
||||
Counts use drill-down semantics (plan decision 2): the count for a value of
|
||||
field F is taken over entries matching every OTHER field's selection (and the
|
||||
malformed toggle), not F's own — so within-field values stay switchable (OR
|
||||
within a field, AND across fields). The returned items list applies ALL
|
||||
selections.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# enum + tags are facetable; text is rendered as a detail control (SLICE-4).
|
||||
FACETABLE_TYPES = {"enum", "tags"}
|
||||
|
||||
|
||||
def facet_fields(fields: dict[str, dict] | None) -> list[tuple[str, str]]:
|
||||
"""Ordered `[(name, type), ...]` facetable from the schema, `state` last.
|
||||
|
||||
Empty when the collection declares no schema (INV-5)."""
|
||||
if not fields:
|
||||
return []
|
||||
out = [
|
||||
(name, spec.get("type"))
|
||||
for name, spec in fields.items()
|
||||
if spec.get("type") in FACETABLE_TYPES
|
||||
]
|
||||
out.append(("state", "enum"))
|
||||
return out
|
||||
|
||||
|
||||
def allowed_filter_keys(fields: dict[str, dict] | None) -> set[str]:
|
||||
"""Query-param keys the collection-scoped list accepts (plan decision 6)."""
|
||||
keys = {name for name, _ in facet_fields(fields)}
|
||||
keys.update({"unreviewed", "malformed"})
|
||||
return keys
|
||||
|
||||
|
||||
def _values_for(entry: dict[str, Any], name: str, ftype: str) -> list[str]:
|
||||
"""The facet value(s) an entry contributes for field `name` (str-cast)."""
|
||||
if name == "state":
|
||||
v = entry.get("state")
|
||||
return [str(v)] if v else []
|
||||
meta = entry.get("meta") or {}
|
||||
v = meta.get(name)
|
||||
if v is None:
|
||||
return []
|
||||
if ftype == "tags":
|
||||
return [str(x) for x in v] if isinstance(v, list) else []
|
||||
return [str(v)]
|
||||
|
||||
|
||||
def _matches(entry: dict[str, Any], name: str, ftype: str, selected: set[str]) -> bool:
|
||||
if not selected:
|
||||
return True
|
||||
return bool(set(_values_for(entry, name, ftype)) & selected) # OR within field
|
||||
|
||||
|
||||
def filter_and_count(
|
||||
entries: list[dict[str, Any]],
|
||||
fields: dict[str, dict] | None,
|
||||
selections: dict[str, set[str]],
|
||||
only_malformed: bool = False,
|
||||
) -> tuple[list[dict[str, Any]], dict[str, dict[str, int]]]:
|
||||
"""Filter `entries` by `selections` and compute drill-down facet counts.
|
||||
|
||||
`selections` maps a facet field name → the set of selected values (OR within
|
||||
the field; AND across fields). `only_malformed` narrows items and counts to
|
||||
entries flagged malformed (INV-3). Returns `(items, facets)` where
|
||||
`facets = {field: {value: count}}`. With no schema → `([all passing], {})`.
|
||||
"""
|
||||
facetable = facet_fields(fields)
|
||||
|
||||
def passes_malformed(e: dict[str, Any]) -> bool:
|
||||
return (not only_malformed) or bool(e.get("metadata_malformed"))
|
||||
|
||||
items = [
|
||||
e for e in entries
|
||||
if passes_malformed(e)
|
||||
and all(_matches(e, n, t, selections.get(n, set())) for n, t in facetable)
|
||||
]
|
||||
|
||||
facets: dict[str, dict[str, int]] = {}
|
||||
for name, ftype in facetable:
|
||||
counts: dict[str, int] = {}
|
||||
for e in entries:
|
||||
if not passes_malformed(e):
|
||||
continue
|
||||
if not all(
|
||||
_matches(e, on, ot, selections.get(on, set()))
|
||||
for on, ot in facetable
|
||||
if on != name
|
||||
):
|
||||
continue
|
||||
for val in _values_for(e, name, ftype):
|
||||
counts[val] = counts.get(val, 0) + 1
|
||||
facets[name] = counts
|
||||
return items, facets
|
||||
@@ -193,6 +193,40 @@ class Gitea:
|
||||
resp = await self._request("PUT", f"/repos/{owner}/{repo}/contents/{path}", json=body)
|
||||
return resp.json()
|
||||
|
||||
async def change_files(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
*,
|
||||
files: list[dict[str, Any]],
|
||||
message: str,
|
||||
branch: str,
|
||||
author_name: str | None = None,
|
||||
author_email: str | None = None,
|
||||
) -> dict:
|
||||
"""Create/update/delete several files in ONE commit (Gitea ChangeFiles).
|
||||
|
||||
Each `files` entry is `{"operation": "create"|"update"|"delete",
|
||||
"path": str, "content": str (for create/update), "sha": str (required
|
||||
for update/delete)}`. Plaintext `content` is base64-encoded here.
|
||||
Backs the §22.4a frontmatter→sidecar migration's "one commit per
|
||||
collection" (`metadata_migrate`).
|
||||
"""
|
||||
out_files: list[dict[str, Any]] = []
|
||||
for f in files:
|
||||
item: dict[str, Any] = {"operation": f["operation"], "path": f["path"]}
|
||||
if "content" in f and f["content"] is not None:
|
||||
item["content"] = base64.b64encode(f["content"].encode("utf-8")).decode("ascii")
|
||||
if f.get("sha"):
|
||||
item["sha"] = f["sha"]
|
||||
out_files.append(item)
|
||||
body: dict[str, Any] = {"message": message, "branch": branch, "files": out_files}
|
||||
if author_name and author_email:
|
||||
body["author"] = {"name": author_name, "email": author_email}
|
||||
body["committer"] = {"name": author_name, "email": author_email}
|
||||
resp = await self._request("POST", f"/repos/{owner}/{repo}/contents", json=body)
|
||||
return resp.json()
|
||||
|
||||
# ----- Pull requests -----
|
||||
|
||||
async def list_pulls(self, owner: str, repo: str, state: str = "open") -> list[dict]:
|
||||
|
||||
@@ -66,6 +66,13 @@ class OtcVerifyBody(BaseModel):
|
||||
trust_device: bool = False
|
||||
|
||||
|
||||
class TestLoginBody(BaseModel):
|
||||
# The single configured test identity to sign in as. Must equal
|
||||
# E2E_TEST_AUTH_EMAIL (case-insensitive) or the request is refused —
|
||||
# see `/auth/test/login`.
|
||||
email: str = Field(min_length=3, max_length=320)
|
||||
|
||||
|
||||
class PasscodeSetBody(BaseModel):
|
||||
passcode: str = Field(min_length=1, max_length=64)
|
||||
|
||||
@@ -101,7 +108,26 @@ async def lifespan(app: FastAPI):
|
||||
config = load_config()
|
||||
db.run_migrations(config)
|
||||
db.init(config)
|
||||
# v0.52.0: shout if the deployed-env E2E test-auth shortcut is live.
|
||||
# It mints owner sessions for one configured identity (see
|
||||
# `/auth/test/login`); it must only ever be on for a pre-prod (PPE)
|
||||
# host. A loud startup line means an accidental prod enablement is
|
||||
# visible in the logs rather than silent.
|
||||
if os.environ.get("E2E_TEST_AUTH_SECRET", "").strip() and os.environ.get(
|
||||
"E2E_TEST_AUTH_EMAIL", ""
|
||||
).strip():
|
||||
log.warning(
|
||||
"E2E TEST-AUTH IS ENABLED: POST /auth/test/login will mint an owner "
|
||||
"session for %s. This must NEVER be set on production.",
|
||||
os.environ["E2E_TEST_AUTH_EMAIL"].strip(),
|
||||
)
|
||||
gitea = Gitea(config)
|
||||
# §22 framework heal: reconcile a divergent default-project collection id
|
||||
# (migration 029's ≥2-projects seed names it after the project, e.g. 'ohm',
|
||||
# but the mirror expects 'default') BEFORE the mirror runs, so the mirror
|
||||
# merges onto the canonical 'default' collection instead of duplicating it.
|
||||
# Idempotent no-op on fresh / single-project / already-aligned deployments.
|
||||
projects.reconcile_default_collection_id(config)
|
||||
# §22.2: mirror the registry before anything reads projects/content_repo.
|
||||
# First boot has no last-good rows, so a missing/invalid registry is fatal
|
||||
# (loud-fail per separation-of-concerns); the reconciler sweep keeps it
|
||||
@@ -380,6 +406,61 @@ def _oauth_router(config) -> APIRouter:
|
||||
"needs_profile": needs_profile,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# v0.52.0: deployed-environment E2E test-auth shortcut.
|
||||
#
|
||||
# Running the Playwright E2E suite against a *deployed* environment
|
||||
# (PPE) is the §9 pre-prod gate. But the deployed env has neither of
|
||||
# the two scaffolds the Tier-1 docker stack relies on for auth: a
|
||||
# Mailpit sink to read the OTC code from, and direct SQLite access to
|
||||
# inject a granted-owner row. This endpoint replaces both with a
|
||||
# single gated gesture: it mints an authenticated OWNER session for
|
||||
# one pre-configured throwaway identity.
|
||||
#
|
||||
# It is FAIL-CLOSED and must never function in production:
|
||||
# * 404 unless BOTH `E2E_TEST_AUTH_SECRET` and `E2E_TEST_AUTH_EMAIL`
|
||||
# are set — a prod deployment that sets neither cannot be coaxed
|
||||
# into minting a session, and the route is invisible.
|
||||
# * The caller must present the shared secret in `X-Test-Auth-Secret`
|
||||
# (constant-time compare); a wrong/absent secret 404s (the route
|
||||
# does not advertise itself to an unauthenticated caller).
|
||||
# * Only the one configured email may be minted; any other address
|
||||
# is refused (403). So an enabled PPE exposes exactly one
|
||||
# throwaway owner identity, with the secret as the trust boundary.
|
||||
#
|
||||
# The hard secrets rule (§6.3) holds: the secret is a Secret Manager
|
||||
# ref injected as env on the VM (never a literal in the repo), and the
|
||||
# E2E runner presents it from SM at runtime (never echoed).
|
||||
@router.post("/auth/test/login")
|
||||
async def test_login(body: TestLoginBody, request: Request):
|
||||
secret = os.environ.get("E2E_TEST_AUTH_SECRET", "").strip()
|
||||
configured_email = os.environ.get("E2E_TEST_AUTH_EMAIL", "").strip()
|
||||
# Feature off (the default, incl. production): route is invisible.
|
||||
if not secret or not configured_email:
|
||||
raise HTTPException(404, "Not Found")
|
||||
presented = request.headers.get("x-test-auth-secret", "")
|
||||
if not secrets.compare_digest(presented, secret):
|
||||
# Don't reveal that the route exists to a caller without the
|
||||
# secret — mirror the "off" shape exactly.
|
||||
raise HTTPException(404, "Not Found")
|
||||
if body.email.strip().lower() != configured_email.lower():
|
||||
raise HTTPException(403, "email not permitted")
|
||||
|
||||
# Provision-or-link the row, then force it to a granted owner so
|
||||
# the metadata write paths (SLICE-4/5) accept it — the deployed
|
||||
# equivalent of the Tier-1 docker-compose backend-seed owner row.
|
||||
user = otc.provision_or_link_user(body.email)
|
||||
db.conn().execute(
|
||||
"UPDATE users SET role = 'owner', permission_state = 'granted', "
|
||||
"last_seen_at = datetime('now') WHERE id = ?",
|
||||
(user.user_id,),
|
||||
)
|
||||
db.conn().commit()
|
||||
user.role = "owner"
|
||||
user.permission_state = "granted"
|
||||
auth.store_session(request, user)
|
||||
return {"ok": True}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# v0.10.0: user-set passcodes after OTC (§6.2, roadmap item #8).
|
||||
#
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""§22 S4 (C.2) — scope-role grant operations over the `memberships` table.
|
||||
|
||||
The membership *gates* (who may invite, who holds which role) live in
|
||||
`auth.py`; this module holds the *mutations* the invitation surface drives —
|
||||
granting, the "broader scope supersedes narrower" cleanup, listing, and
|
||||
revocation — mirroring how `invites.py` owns the create/claim/list of per-user
|
||||
invite tokens while the gate (`auth.can_invite_to_rfc`) lives in `auth.py`.
|
||||
|
||||
The model (Part B / S3): a `memberships` row is `(scope_type ∈ {global,
|
||||
project, collection}, scope_id, user_id, role ∈ {owner, contributor})`, unique
|
||||
per `(scope_type, scope_id, user_id)`. A grant is a direct write of that row
|
||||
(the C.2 scenarios write the row immediately and §15-notify an existing
|
||||
account — there is no accept round-trip; inviting a not-yet-account email is
|
||||
out of S4 scope and handled by the admin-create-invite path).
|
||||
|
||||
The "broader scope supersedes narrower" rule (C.2.6): granting a role at a
|
||||
broader scope removes this user's narrower rows that the new grant *subsumes*
|
||||
— a narrower row whose role is no more permissive than the new one. A narrower
|
||||
row that is *more* permissive is kept (no negative override: a child Owner
|
||||
grant survives a parent Contributor grant, and the §B.2 resolver still unions
|
||||
most-permissively).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from . import collections as collections_mod
|
||||
from . import db
|
||||
|
||||
# Higher rank = more permissive. Used by the supersede rule: a narrower grant
|
||||
# is pruned only when its rank ≤ the new broader grant's rank.
|
||||
_ROLE_RANK = {"contributor": 1, "owner": 2}
|
||||
|
||||
VALID_SCOPE_TYPES = ("global", "project", "collection")
|
||||
VALID_ROLES = ("owner", "contributor")
|
||||
GLOBAL_SCOPE_ID = "*"
|
||||
|
||||
|
||||
def user_by_email(email: str) -> dict[str, Any] | None:
|
||||
"""The `users` row (id, display_name, email, permission_state) for an
|
||||
email, case-insensitively, or None. The grantee must already be an account
|
||||
— S4 grants a scope role to an existing user, it does not provision one."""
|
||||
row = db.conn().execute(
|
||||
"SELECT id, display_name, email, permission_state, role "
|
||||
"FROM users WHERE lower(email) = lower(?) "
|
||||
"ORDER BY id LIMIT 1",
|
||||
(email.strip(),),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def grant(
|
||||
*,
|
||||
scope_type: str,
|
||||
scope_id: str,
|
||||
user_id: int,
|
||||
role: str,
|
||||
granted_by: int | None,
|
||||
) -> None:
|
||||
"""Write (or update) the membership row, then apply the C.2.6
|
||||
broader-scope-supersedes cleanup. Idempotent on `(scope_type, scope_id,
|
||||
user_id)` — re-granting at the same scope updates the role and the grantor.
|
||||
|
||||
The grant is recorded regardless of the grantee's deployment
|
||||
`permission_state`: a `pending` account's row is written (C.2.7), but the
|
||||
§6 admission floor in `auth.effective_scope_role` keeps it conferring no
|
||||
write until the account is granted at the deployment."""
|
||||
db.conn().execute(
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role, granted_by) "
|
||||
"VALUES (?, ?, ?, ?, ?) "
|
||||
"ON CONFLICT (scope_type, scope_id, user_id) "
|
||||
"DO UPDATE SET role = excluded.role, granted_by = excluded.granted_by, "
|
||||
"granted_at = datetime('now')",
|
||||
(scope_type, scope_id, user_id, role, granted_by),
|
||||
)
|
||||
_prune_subsumed(scope_type=scope_type, scope_id=scope_id, user_id=user_id, role=role)
|
||||
|
||||
|
||||
def _prune_subsumed(*, scope_type: str, scope_id: str, user_id: int, role: str) -> None:
|
||||
"""Remove this user's narrower rows that the just-written broader grant
|
||||
subsumes (same-or-lower role rank within the broader scope's subtree). A
|
||||
collection grant subsumes nothing narrower (the per-entry tier is separate);
|
||||
a project grant subsumes its collections; a global grant subsumes every
|
||||
project and collection."""
|
||||
rank = _ROLE_RANK[role]
|
||||
keep_ranks = [r for r, v in _ROLE_RANK.items() if v <= rank]
|
||||
if not keep_ranks:
|
||||
return
|
||||
placeholders = ",".join("?" for _ in keep_ranks)
|
||||
if scope_type == "project":
|
||||
# Narrower = collection-scope rows for collections in this project.
|
||||
db.conn().execute(
|
||||
f"DELETE FROM memberships "
|
||||
f"WHERE user_id = ? AND scope_type = 'collection' "
|
||||
f" AND role IN ({placeholders}) "
|
||||
f" AND scope_id IN (SELECT id FROM collections WHERE project_id = ?)",
|
||||
(user_id, *keep_ranks, scope_id),
|
||||
)
|
||||
elif scope_type == "global":
|
||||
# Narrower = every project- and collection-scope row for this user.
|
||||
db.conn().execute(
|
||||
f"DELETE FROM memberships "
|
||||
f"WHERE user_id = ? AND scope_type IN ('project', 'collection') "
|
||||
f" AND role IN ({placeholders})",
|
||||
(user_id, *keep_ranks),
|
||||
)
|
||||
|
||||
|
||||
def revoke(*, scope_type: str, scope_id: str, user_id: int) -> bool:
|
||||
"""Remove a membership row at exactly this scope. Returns True if a row was
|
||||
removed. Revocation is scope-exact: it does not cascade to broader or
|
||||
narrower grants (each is its own administrative act)."""
|
||||
cur = db.conn().execute(
|
||||
"DELETE FROM memberships WHERE scope_type = ? AND scope_id = ? AND user_id = ?",
|
||||
(scope_type, scope_id, user_id),
|
||||
)
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def list_for_project(project_id: str) -> list[dict[str, Any]]:
|
||||
"""Every project-scope grant on this project plus every collection-scope
|
||||
grant on its collections, joined to the grantee's display fields — the data
|
||||
behind the project-Owner membership panel. Ordered project grants first,
|
||||
then by collection, then by role (Owner before Contributor)."""
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT m.scope_type, m.scope_id, m.user_id, m.role, m.granted_at,
|
||||
u.display_name, u.email, u.permission_state,
|
||||
c.name AS collection_name
|
||||
FROM memberships m
|
||||
JOIN users u ON u.id = m.user_id
|
||||
LEFT JOIN collections c ON c.id = m.scope_id AND m.scope_type = 'collection'
|
||||
WHERE (m.scope_type = 'project' AND m.scope_id = ?)
|
||||
OR (m.scope_type = 'collection'
|
||||
AND m.scope_id IN (SELECT id FROM collections WHERE project_id = ?))
|
||||
ORDER BY (m.scope_type != 'project'), m.scope_id,
|
||||
CASE m.role WHEN 'owner' THEN 0 ELSE 1 END, u.display_name
|
||||
""",
|
||||
(project_id, project_id),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
@@ -0,0 +1,311 @@
|
||||
"""§22.4a configurable collection metadata — sidecar storage + dual-read.
|
||||
|
||||
SLICE-1 of docs/design/2026-06-06-configurable-collection-metadata.md.
|
||||
|
||||
Entry metadata is collection-configured and stored in a per-entry sidecar,
|
||||
`<slug>.meta.yaml`, with the `.md` body kept as pure prose (INV-2). This module
|
||||
is the storage/compat layer:
|
||||
|
||||
- the **dual-read** parser (`read_entry`) — read the sidecar if present, else
|
||||
legacy top-of-document frontmatter, with identical resulting records
|
||||
(INV-6);
|
||||
- sidecar (de)serialization that preserves unknown / forward-compat keys
|
||||
(INV-7), reusing `entry`'s canonical field semantics;
|
||||
- lenient parsing that never hard-fails a read — a malformed sidecar surfaces
|
||||
a flag, not an exception (INV-3).
|
||||
|
||||
The collection `fields:` schema and per-field validation are SLICE-2; faceted
|
||||
filtering and the edit UIs are later slices. This module interprets no field
|
||||
values — it only moves metadata between git and in-memory `Entry` records.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from . import entry as entry_mod
|
||||
from .entry import Entry
|
||||
|
||||
SIDECAR_SUFFIX = ".meta.yaml"
|
||||
|
||||
|
||||
# ----- filename helpers -----
|
||||
|
||||
def sidecar_name(slug: str) -> str:
|
||||
"""The sidecar filename for an entry whose markdown is `<slug>.md`."""
|
||||
return f"{slug}{SIDECAR_SUFFIX}"
|
||||
|
||||
|
||||
def is_sidecar(name: str) -> bool:
|
||||
return name.endswith(SIDECAR_SUFFIX)
|
||||
|
||||
|
||||
def slug_of_sidecar(name: str) -> str:
|
||||
"""The entry stem for a `<slug>.meta.yaml` filename."""
|
||||
return name[: -len(SIDECAR_SUFFIX)] if is_sidecar(name) else name
|
||||
|
||||
|
||||
def sidecar_path_for(md_path: str) -> str:
|
||||
"""The sidecar path sibling to a `<dir>/<slug>.md` entry file."""
|
||||
assert md_path.endswith(".md"), md_path
|
||||
return md_path[: -len(".md")] + SIDECAR_SUFFIX
|
||||
|
||||
|
||||
# ----- metadata <-> sidecar -----
|
||||
|
||||
def metadata_dict(entry: Entry) -> dict[str, Any]:
|
||||
"""The full metadata mapping for an entry (known fields + `extra`)."""
|
||||
return entry_mod.to_frontmatter_dict(entry)
|
||||
|
||||
|
||||
def sidecar_yaml(entry: Entry) -> str:
|
||||
"""Render an entry's metadata as standalone `<slug>.meta.yaml` text."""
|
||||
return yaml.safe_dump(
|
||||
metadata_dict(entry), sort_keys=False, default_flow_style=False
|
||||
)
|
||||
|
||||
|
||||
def parse_sidecar(text: str) -> tuple[dict[str, Any], bool]:
|
||||
"""Parse sidecar YAML leniently → `(values, malformed)`.
|
||||
|
||||
`malformed` is True when the text is not a YAML mapping (a list, a scalar,
|
||||
or a YAML syntax error). An empty / whitespace-only sidecar is an empty
|
||||
mapping, not malformed. Never raises (INV-3).
|
||||
"""
|
||||
try:
|
||||
raw = yaml.safe_load(text)
|
||||
except yaml.YAMLError:
|
||||
return {}, True
|
||||
if raw is None:
|
||||
return {}, False
|
||||
if not isinstance(raw, dict):
|
||||
return {}, True
|
||||
return raw, False
|
||||
|
||||
|
||||
# ----- frontmatter stripping (INV-2) -----
|
||||
|
||||
def strip_frontmatter(md_text: str) -> str:
|
||||
"""Return the prose body of a `.md`, dropping a leading `---…---` block.
|
||||
|
||||
A migrated entry's `.md` is body-only and passes through unchanged. A
|
||||
not-yet-migrated `.md` still carrying frontmatter yields just its body, so
|
||||
the dual-read body is the same either way.
|
||||
"""
|
||||
match = entry_mod.FRONTMATTER_RE.match(md_text)
|
||||
if not match:
|
||||
return md_text
|
||||
return match.group(2).lstrip("\n")
|
||||
|
||||
|
||||
# ----- dual-read (INV-6) -----
|
||||
|
||||
def read_entry(
|
||||
md_text: str, sidecar_text: str | None, *, fallback_slug: str | None = None
|
||||
) -> tuple[Entry, bool]:
|
||||
"""Read an entry from its `.md` and optional sidecar → `(Entry, malformed)`.
|
||||
|
||||
- **Sidecar present and well-formed (non-empty):** metadata comes from the
|
||||
sidecar; the body is the `.md` stripped of any leading frontmatter. The
|
||||
sidecar is the source of truth (INV-1) and wins over stale `.md`
|
||||
frontmatter.
|
||||
- **Sidecar present but malformed:** the entry still loads from the legacy
|
||||
`.md` frontmatter (if any) and is flagged `malformed` (INV-3).
|
||||
- **Sidecar present but empty:** it has nothing to override with, so fall
|
||||
back to the `.md` frontmatter (not flagged).
|
||||
- **No sidecar:** the legacy path — parse the `.md` frontmatter (INV-6).
|
||||
|
||||
`fallback_slug` (typically the filename stem) backstops the entry's slug
|
||||
whenever the metadata source lacks one — so a degenerate sidecar never
|
||||
yields a slug-less record the caller has to silently drop (INV-3).
|
||||
"""
|
||||
def _with_slug(entry: Entry) -> Entry:
|
||||
if not entry.slug and fallback_slug:
|
||||
entry.slug = fallback_slug
|
||||
return entry
|
||||
|
||||
if sidecar_text is None:
|
||||
return _with_slug(entry_mod.parse(md_text)), False
|
||||
|
||||
values, malformed = parse_sidecar(sidecar_text)
|
||||
if malformed or not values:
|
||||
# Malformed or empty sidecar: load from the legacy .md so the entry
|
||||
# still loads; flag only when the sidecar was actually malformed.
|
||||
try:
|
||||
entry = entry_mod.parse(md_text)
|
||||
except ValueError:
|
||||
entry = entry_mod.from_frontmatter({}, strip_frontmatter(md_text))
|
||||
return _with_slug(entry), malformed
|
||||
|
||||
body = strip_frontmatter(md_text)
|
||||
return _with_slug(entry_mod.from_frontmatter(values, body)), False
|
||||
|
||||
|
||||
# ----- value editing (SLICE-4) -----
|
||||
|
||||
def apply_values(entry: Entry, values: dict[str, Any]) -> Entry:
|
||||
"""Return a new Entry with `values` merged over the entry's metadata.
|
||||
|
||||
Known keys (`tags`, `state`, `reviewed_by`, …) land on their typed fields;
|
||||
unknown keys land on `extra` (INV-7). The body is carried through unchanged
|
||||
— this mutates metadata only. Unspecified keys are preserved.
|
||||
"""
|
||||
merged = metadata_dict(entry)
|
||||
merged.update(values)
|
||||
return entry_mod.from_frontmatter(merged, entry.body)
|
||||
|
||||
|
||||
# ----- git-aware read/write (SLICE-4) -----
|
||||
|
||||
@dataclass
|
||||
class EntryGitState:
|
||||
"""An entry's on-disk state across its `.md` and optional sidecar.
|
||||
|
||||
Captured by `read_entry_from_git` and consumed by `write_entry_files` to
|
||||
decide create-vs-update for the sidecar and whether the `.md` still needs
|
||||
its frontmatter stripped (lazy migration).
|
||||
"""
|
||||
entry: Entry
|
||||
md_text: str
|
||||
md_sha: str
|
||||
sidecar_text: str | None
|
||||
sidecar_sha: str | None
|
||||
malformed: bool
|
||||
|
||||
|
||||
async def read_entry_from_git(
|
||||
gitea: Any, org: str, repo: str, md_path: str, *, ref: str = "main"
|
||||
) -> "EntryGitState | None":
|
||||
"""Dual-read an entry from git → `EntryGitState`, or None if the `.md` is
|
||||
missing. Reads the `.md` and its sibling sidecar (if any); never raises on
|
||||
bad metadata (INV-3)."""
|
||||
md = await gitea.read_file(org, repo, md_path, ref=ref)
|
||||
if md is None:
|
||||
return None
|
||||
md_text, md_sha = md
|
||||
sc_path = sidecar_path_for(md_path)
|
||||
sc = await gitea.read_file(org, repo, sc_path, ref=ref)
|
||||
sidecar_text, sidecar_sha = (sc[0], sc[1]) if sc else (None, None)
|
||||
stem = md_path.rsplit("/", 1)[-1][: -len(".md")]
|
||||
entry, malformed = read_entry(md_text, sidecar_text, fallback_slug=stem)
|
||||
return EntryGitState(
|
||||
entry=entry, md_text=md_text, md_sha=md_sha,
|
||||
sidecar_text=sidecar_text, sidecar_sha=sidecar_sha, malformed=malformed,
|
||||
)
|
||||
|
||||
|
||||
def _md_has_frontmatter(md_text: str) -> bool:
|
||||
return entry_mod.FRONTMATTER_RE.match(md_text) is not None
|
||||
|
||||
|
||||
def write_entry_files(
|
||||
md_path: str, entry: Entry, state: "EntryGitState"
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Produce `change_files` ops that persist `entry`'s metadata to its sidecar
|
||||
and keep the `.md` as pure prose (INV-1/INV-2).
|
||||
|
||||
- Sidecar: `create` when none existed, else `update` at its prior sha.
|
||||
- `.md`: rewritten body-only **only when it still carries frontmatter**
|
||||
(lazy migration, INV-6); an already-clean body is left untouched.
|
||||
"""
|
||||
sc_path = sidecar_path_for(md_path)
|
||||
ops: list[dict[str, Any]] = []
|
||||
sc_op: dict[str, Any] = {
|
||||
"operation": "update" if state.sidecar_sha else "create",
|
||||
"path": sc_path,
|
||||
"content": sidecar_yaml(entry),
|
||||
}
|
||||
if state.sidecar_sha:
|
||||
sc_op["sha"] = state.sidecar_sha
|
||||
ops.append(sc_op)
|
||||
if _md_has_frontmatter(state.md_text):
|
||||
body = strip_frontmatter(state.md_text)
|
||||
new_md = body if (body == "" or body.endswith("\n")) else body + "\n"
|
||||
ops.append({
|
||||
"operation": "update", "path": md_path,
|
||||
"content": new_md, "sha": state.md_sha,
|
||||
})
|
||||
return ops
|
||||
|
||||
|
||||
# ----- frontmatter -> sidecar migration (PUC-5) -----
|
||||
|
||||
async def migrate_collection(
|
||||
gitea: Any,
|
||||
*,
|
||||
org: str,
|
||||
repo: str,
|
||||
subfolder: str = "",
|
||||
actor: Any = None,
|
||||
branch: str = "main",
|
||||
) -> dict[str, Any]:
|
||||
"""Migrate a collection's legacy-frontmatter entries to sidecars.
|
||||
|
||||
Walks `<subfolder>/rfcs`; for each `<slug>.md` that has legacy frontmatter
|
||||
and **no** `<slug>.meta.yaml` sibling yet, it stages two file changes —
|
||||
create the sidecar (the entry's metadata, unknown keys preserved, INV-7)
|
||||
and rewrite the `.md` to body-only (INV-2) — and commits all of them in a
|
||||
single ChangeFiles commit (§6.5: one commit per collection).
|
||||
|
||||
Idempotent: an entry that already has a sidecar is skipped; a second run
|
||||
with nothing left to migrate makes no commit. Returns
|
||||
`{"migrated": [...], "skipped": [...], "committed": bool}`.
|
||||
"""
|
||||
rfcs_dir = f"{subfolder}/rfcs" if subfolder else "rfcs"
|
||||
listing = await gitea.list_dir(org, repo, rfcs_dir, ref=branch)
|
||||
names = {f.get("name") for f in listing if f.get("type") == "file"}
|
||||
|
||||
ops: list[dict[str, Any]] = []
|
||||
migrated: list[str] = []
|
||||
skipped: list[str] = []
|
||||
for f in listing:
|
||||
if f.get("type") != "file" or not f.get("name", "").endswith(".md"):
|
||||
continue
|
||||
slug = f["name"][:-len(".md")]
|
||||
if sidecar_name(slug) in names:
|
||||
skipped.append(slug) # already migrated
|
||||
continue
|
||||
result = await gitea.read_file(org, repo, f["path"], ref=branch)
|
||||
if not result:
|
||||
continue
|
||||
text, sha = result
|
||||
try:
|
||||
e = entry_mod.parse(text)
|
||||
except ValueError:
|
||||
# No frontmatter to lift (e.g. an already-clean body without a
|
||||
# sidecar) — nothing to migrate; leave it untouched.
|
||||
skipped.append(slug)
|
||||
continue
|
||||
body = strip_frontmatter(text)
|
||||
new_md = body if (body == "" or body.endswith("\n")) else body + "\n"
|
||||
ops.append({
|
||||
"operation": "create",
|
||||
"path": f"{rfcs_dir}/{sidecar_name(slug)}",
|
||||
"content": sidecar_yaml(e),
|
||||
})
|
||||
ops.append({
|
||||
"operation": "update",
|
||||
"path": f["path"],
|
||||
"content": new_md,
|
||||
"sha": sha,
|
||||
})
|
||||
migrated.append(slug)
|
||||
|
||||
committed = False
|
||||
if ops:
|
||||
n = len(migrated)
|
||||
message = f"Migrate {n} entr{'y' if n == 1 else 'ies'} to metadata sidecars (§22.4a)"
|
||||
kwargs: dict[str, Any] = {}
|
||||
if actor is not None:
|
||||
kwargs = {
|
||||
"author_name": actor.display_name,
|
||||
"author_email": actor.email or f"{actor.gitea_login}@users.noreply",
|
||||
}
|
||||
await gitea.change_files(
|
||||
org, repo, files=ops, message=message, branch=branch, **kwargs
|
||||
)
|
||||
committed = True
|
||||
|
||||
return {"migrated": migrated, "skipped": skipped, "committed": committed}
|
||||
@@ -0,0 +1,147 @@
|
||||
"""§22.4a configurable collection metadata — field schema + central validation.
|
||||
|
||||
SLICE-2 of docs/design/2026-06-06-configurable-collection-metadata.md.
|
||||
|
||||
A collection declares a small **field schema** in its `.collection.yaml`
|
||||
(`fields:` block) so its entries can carry structured metadata — priority, tags,
|
||||
and any custom fields the deployment defines. This module is the **one place**
|
||||
that knows a collection's field shapes (modeled on `registry.py`):
|
||||
|
||||
- `parse_fields` — normalize + validate the declared schema, leniently: a bad
|
||||
block or a bad field def is skipped with a warning, never raised, so a typo
|
||||
in one field can't nuke the collection mirror (INV-3 spirit). The normalized
|
||||
schema is a plain, JSON-serializable mapping that rides in
|
||||
`collections.config_json` (no DB migration) and is served verbatim by the
|
||||
collection API.
|
||||
- `validate` — check an entry's stored values against the schema, returning a
|
||||
list of advisory `Problem`s. Empty list = clean. Used **advisory at read**
|
||||
(the corpus mirror flags a non-empty result as `metadata_malformed`, INV-3)
|
||||
and is the enforcement point at the **write** boundary (the metadata-edit
|
||||
endpoints land in SLICE-4/5).
|
||||
|
||||
Field types (v1): `enum` (single scalar, controlled by a required `values:`
|
||||
list), `tags` (a list; free-form unless `values:` given), `text` (a free
|
||||
string). `ref` / `multi-enum` are future (design §2, Q2). Unknown types are
|
||||
ignored with a warning. Keys an entry carries that the schema does **not**
|
||||
declare ride along untouched and are never flagged (INV-7).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# §6.3 v1 field types. `ref` (typed cross-entry link) and `multi-enum` are
|
||||
# deferred (design §2, Q2) — declared with an unknown type they're skipped.
|
||||
VALID_FIELD_TYPES = {"enum", "tags", "text"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Problem:
|
||||
"""One advisory schema-validation problem against a declared field.
|
||||
|
||||
`code` is a stable machine token (`not-in-values`, `wrong-type`); `message`
|
||||
is human-facing (surfaced at the write boundary in SLICE-4/5)."""
|
||||
field: str
|
||||
code: str
|
||||
message: str
|
||||
|
||||
def as_dict(self) -> dict[str, str]:
|
||||
return {"field": self.field, "code": self.code, "message": self.message}
|
||||
|
||||
|
||||
# ----- schema parsing (lenient) -----
|
||||
|
||||
def parse_fields(raw: Any) -> dict[str, dict]:
|
||||
"""Normalize a `.collection.yaml` `fields:` block → `{name: {type, ...}}`.
|
||||
|
||||
Pure (no I/O). Lenient (INV-3): a non-mapping block yields `{}`; an
|
||||
individual field def that is not a mapping, has an unknown/missing `type`, or
|
||||
is an `enum` without a non-empty `values:` list is **skipped with a warning**
|
||||
— never raised. Order is preserved (facet display order, SLICE-3). The
|
||||
result is plain dicts so it serializes straight into `config_json` and the
|
||||
collection API.
|
||||
"""
|
||||
if not isinstance(raw, dict):
|
||||
if raw is not None:
|
||||
log.warning("metadata_schema: fields block is not a mapping (%s); ignoring",
|
||||
type(raw).__name__)
|
||||
return {}
|
||||
out: dict[str, dict] = {}
|
||||
for name, spec in raw.items():
|
||||
if not isinstance(spec, dict):
|
||||
log.warning("metadata_schema: field %r def is not a mapping; skipping", name)
|
||||
continue
|
||||
ftype = str(spec.get("type") or "").strip()
|
||||
if ftype not in VALID_FIELD_TYPES:
|
||||
log.warning("metadata_schema: field %r has unknown type %r; skipping",
|
||||
name, ftype)
|
||||
continue
|
||||
values = spec.get("values")
|
||||
norm_values: list[str] | None = None
|
||||
if values is not None:
|
||||
if not isinstance(values, list):
|
||||
log.warning("metadata_schema: field %r values is not a list; ignoring",
|
||||
name)
|
||||
else:
|
||||
norm_values = [str(v) for v in values]
|
||||
if ftype == "enum" and not norm_values:
|
||||
log.warning("metadata_schema: enum field %r needs a non-empty values "
|
||||
"list; skipping", name)
|
||||
continue
|
||||
field_def: dict[str, Any] = {"type": ftype}
|
||||
if norm_values is not None:
|
||||
field_def["values"] = norm_values
|
||||
label = spec.get("label")
|
||||
if label:
|
||||
field_def["label"] = str(label)
|
||||
out[str(name)] = field_def
|
||||
return out
|
||||
|
||||
|
||||
# ----- value validation (advisory) -----
|
||||
|
||||
def _is_scalar(v: Any) -> bool:
|
||||
return isinstance(v, (str, int, float, bool))
|
||||
|
||||
|
||||
def validate(values: dict[str, Any], fields: dict[str, dict]) -> list[Problem]:
|
||||
"""Check an entry's metadata `values` against a collection's field schema.
|
||||
|
||||
Returns advisory `Problem`s (empty = clean). Only **declared** fields are
|
||||
checked; a field the entry omits is fine (no required fields in v1), and a
|
||||
key the schema doesn't declare rides along untouched (INV-7). With an empty
|
||||
schema, everything is clean (INV-5). Never raises (INV-3).
|
||||
"""
|
||||
problems: list[Problem] = []
|
||||
for name, spec in fields.items():
|
||||
if name not in values:
|
||||
continue
|
||||
value = values[name]
|
||||
if value is None:
|
||||
continue
|
||||
ftype = spec.get("type")
|
||||
allowed = spec.get("values")
|
||||
if ftype == "enum":
|
||||
if not _is_scalar(value):
|
||||
problems.append(Problem(name, "wrong-type",
|
||||
f"{name!r} must be a single value, got {type(value).__name__}"))
|
||||
elif allowed is not None and str(value) not in allowed:
|
||||
problems.append(Problem(name, "not-in-values",
|
||||
f"{name!r} value {value!r} is not one of {allowed}"))
|
||||
elif ftype == "tags":
|
||||
if not isinstance(value, list):
|
||||
problems.append(Problem(name, "wrong-type",
|
||||
f"{name!r} must be a list, got {type(value).__name__}"))
|
||||
elif allowed is not None:
|
||||
for member in value:
|
||||
if str(member) not in allowed:
|
||||
problems.append(Problem(name, "not-in-values",
|
||||
f"{name!r} value {member!r} is not one of {allowed}"))
|
||||
elif ftype == "text":
|
||||
if not _is_scalar(value):
|
||||
problems.append(Problem(name, "wrong-type",
|
||||
f"{name!r} must be a string, got {type(value).__name__}"))
|
||||
return problems
|
||||
@@ -38,22 +38,78 @@ from . import db, funder
|
||||
from .providers import BaseProvider
|
||||
|
||||
|
||||
def _models_from_config(config_json: str | None) -> list[str] | None:
|
||||
"""The `enabled_models` list inside a project/collection `config_json`,
|
||||
or None when the key is absent (meaning "no narrowing at this tier")."""
|
||||
if not config_json:
|
||||
return None
|
||||
try:
|
||||
cfg = json.loads(config_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
em = cfg.get("enabled_models") if isinstance(cfg, dict) else None
|
||||
return [str(m) for m in em] if isinstance(em, list) else None
|
||||
|
||||
|
||||
def _narrow(universe: list[str], allowed: list[str] | None) -> list[str]:
|
||||
"""Intersect `universe` with `allowed`, preserving universe order. `allowed`
|
||||
None means no narrowing at this tier; an empty list narrows to empty (an
|
||||
opt-out), exactly like the §6.6 per-entry `models: []`."""
|
||||
if allowed is None:
|
||||
return universe
|
||||
allow = set(allowed)
|
||||
return [k for k in universe if k in allow]
|
||||
|
||||
|
||||
def _scope_narrowed_universe(
|
||||
collection_id: str | None, operator_keys: list[str]
|
||||
) -> list[str]:
|
||||
"""§22.12 — narrow the operator (deployment) universe by the entry's
|
||||
project then its collection `enabled_models`. Each tier may only narrow;
|
||||
a missing config at a tier is a no-op. The collection cannot widen its
|
||||
project because narrowing composes from the operator ceiling downward."""
|
||||
if collection_id is None:
|
||||
return list(operator_keys)
|
||||
conn = db.conn()
|
||||
crow = conn.execute(
|
||||
"SELECT project_id, config_json FROM collections WHERE id = ?",
|
||||
(collection_id,),
|
||||
).fetchone()
|
||||
universe = list(operator_keys)
|
||||
if crow is None:
|
||||
return universe
|
||||
prow = conn.execute(
|
||||
"SELECT config_json FROM projects WHERE id = ?", (crow["project_id"],)
|
||||
).fetchone()
|
||||
universe = _narrow(universe, _models_from_config(prow["config_json"] if prow else None))
|
||||
universe = _narrow(universe, _models_from_config(crow["config_json"]))
|
||||
return universe
|
||||
|
||||
|
||||
def resolve_models_for_rfc(
|
||||
slug: str, providers: dict[str, BaseProvider]
|
||||
) -> list[str]:
|
||||
"""Return the per-RFC resolved model keys per §6.6, extended by §6.7.
|
||||
"""Return the per-RFC resolved model keys per §6.6, extended by §6.7 and
|
||||
§22.12.
|
||||
|
||||
The first entry is the RFC's default model. An empty list means
|
||||
AI is unavailable on this RFC and callers refuse the AI surface.
|
||||
"""
|
||||
# §6.7: the funder universe (if any) replaces the operator universe
|
||||
# as the base set the §6.6 frontmatter intersects against.
|
||||
funder_universe = funder.resolve_funder_universe(slug, providers)
|
||||
base_universe = funder_universe if funder_universe is not None else list(providers.keys())
|
||||
row = db.conn().execute(
|
||||
"SELECT models_json FROM cached_rfcs WHERE slug = ?",
|
||||
"SELECT collection_id, models_json FROM cached_rfcs WHERE slug = ?",
|
||||
(slug,),
|
||||
).fetchone()
|
||||
collection_id = row["collection_id"] if row is not None else None
|
||||
# §22.12: first narrow the operator universe by the entry's project +
|
||||
# collection enabled_models (the deployment → project → collection chain).
|
||||
scope_universe = _scope_narrowed_universe(collection_id, list(providers.keys()))
|
||||
# §6.7: a consenting funder universe (if any) replaces the operator universe
|
||||
# as the base set — still bounded by the §22.12 scope narrowing above.
|
||||
funder_universe = funder.resolve_funder_universe(slug, providers)
|
||||
if funder_universe is not None:
|
||||
base_universe = _narrow(list(funder_universe), scope_universe)
|
||||
else:
|
||||
base_universe = scope_universe
|
||||
if row is None or row["models_json"] is None:
|
||||
return list(base_universe)
|
||||
try:
|
||||
|
||||
@@ -324,6 +324,48 @@ def fan_out_contribution_request(
|
||||
return notif_ids
|
||||
|
||||
|
||||
def notify_scope_role_granted(
|
||||
*,
|
||||
recipient_user_id: int,
|
||||
granter_user_id: int | None,
|
||||
scope_type: str,
|
||||
scope_id: str,
|
||||
role: str,
|
||||
project_id: str | None,
|
||||
project_name: str | None,
|
||||
collection_name: str | None,
|
||||
) -> int | None:
|
||||
"""§22 S4 (C.2): a scope Owner granted `recipient` a role at a scope.
|
||||
Personal-direct — the recipient is the named subject — so it rides the
|
||||
`email_personal_direct` gate like the other owner-facing personal events.
|
||||
The scope facts ride in the payload so the inbox row (and email body) names
|
||||
the project and role without a second fetch. Actor is the granter (§15.9);
|
||||
a system/administrative grant with no granter renders as "the app".
|
||||
|
||||
Returns the notification id, or None when the grantee would be notifying
|
||||
themselves (a self-grant — no notification)."""
|
||||
if granter_user_id is not None and recipient_user_id == granter_user_id:
|
||||
return None
|
||||
details = {
|
||||
"scope_type": scope_type,
|
||||
"scope_id": scope_id,
|
||||
"role": role,
|
||||
"project_id": project_id or "",
|
||||
"project_name": project_name or "",
|
||||
"collection_name": collection_name or "",
|
||||
}
|
||||
return _emit_one(
|
||||
recipient_user_id=recipient_user_id,
|
||||
event_kind="scope_role_granted",
|
||||
category=CATEGORY_PERSONAL,
|
||||
actor_user_id=granter_user_id,
|
||||
rfc_slug=None,
|
||||
branch_name=None,
|
||||
pr_number=None,
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
def notify_contribution_decided(
|
||||
*,
|
||||
rfc_slug: str,
|
||||
@@ -350,6 +392,95 @@ def notify_contribution_decided(
|
||||
)
|
||||
|
||||
|
||||
def fan_out_join_request(
|
||||
*,
|
||||
scope_type: str,
|
||||
scope_id: str,
|
||||
scope_name: str | None,
|
||||
project_id: str | None,
|
||||
project_name: str | None,
|
||||
requester_user_id: int,
|
||||
request_id: int,
|
||||
requested_role: str,
|
||||
message: str | None,
|
||||
) -> list[int]:
|
||||
"""§22.8: a user asked to join a scope. Land one actionable notification per
|
||||
Owner across the scope's subtree (the cross-collection inbox, §22.11) and
|
||||
return their ids (the caller stamps the first onto the request row as the
|
||||
inbox-action handle — any of them can act on it).
|
||||
|
||||
Personal-direct: each Owner is a named subject able to act, so it rides the
|
||||
`email_personal_direct` gate like the other owner-facing personal events. The
|
||||
requested role + message ride in the payload so the inbox row shows the full
|
||||
ask inline. Actor is the requester per §15.9.
|
||||
"""
|
||||
requester = db.conn().execute(
|
||||
"SELECT display_name FROM users WHERE id = ?", (requester_user_id,)
|
||||
).fetchone()
|
||||
display = (requester["display_name"] if requester else None) or "Someone"
|
||||
details = {
|
||||
"request_id": request_id,
|
||||
"scope_type": scope_type,
|
||||
"scope_id": scope_id,
|
||||
"scope_name": scope_name or scope_id,
|
||||
"project_id": project_id or "",
|
||||
"project_name": project_name or "",
|
||||
"requested_role": requested_role,
|
||||
"requester_user_id": requester_user_id,
|
||||
"requester_display": display,
|
||||
"message": message or "",
|
||||
}
|
||||
notif_ids: list[int] = []
|
||||
for recipient_id in _scope_owner_user_ids(scope_type, scope_id):
|
||||
if recipient_id == requester_user_id:
|
||||
continue
|
||||
notif_ids.append(
|
||||
_emit_one(
|
||||
recipient_user_id=recipient_id,
|
||||
event_kind="join_request_on_scope",
|
||||
category=CATEGORY_PERSONAL,
|
||||
actor_user_id=requester_user_id,
|
||||
rfc_slug=None,
|
||||
branch_name=None,
|
||||
pr_number=None,
|
||||
details=details,
|
||||
)
|
||||
)
|
||||
return notif_ids
|
||||
|
||||
|
||||
def notify_join_decided(
|
||||
*,
|
||||
requester_user_id: int,
|
||||
decider_user_id: int,
|
||||
request_id: int,
|
||||
scope_type: str,
|
||||
scope_id: str,
|
||||
scope_name: str | None,
|
||||
granted_role: str | None,
|
||||
accepted: bool,
|
||||
) -> None:
|
||||
"""§22.8: tell the requester an Owner accepted (writing their `memberships`
|
||||
row) or declined their request to join. The scope + granted role ride in the
|
||||
payload so the inbox row names where they were let in without a second fetch."""
|
||||
_emit_one(
|
||||
recipient_user_id=requester_user_id,
|
||||
event_kind=("join_request_accepted" if accepted else "join_request_declined"),
|
||||
category=CATEGORY_PERSONAL,
|
||||
actor_user_id=decider_user_id,
|
||||
rfc_slug=None,
|
||||
branch_name=None,
|
||||
pr_number=None,
|
||||
details={
|
||||
"request_id": request_id,
|
||||
"scope_type": scope_type,
|
||||
"scope_id": scope_id,
|
||||
"scope_name": scope_name or scope_id,
|
||||
"granted_role": granted_role or "",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def fan_out_chat_message(
|
||||
*,
|
||||
actor_user_id: int,
|
||||
@@ -625,6 +756,41 @@ def _admin_user_ids() -> set[int]:
|
||||
}
|
||||
|
||||
|
||||
def _scope_owner_user_ids(scope_type: str, scope_id: str) -> set[int]:
|
||||
"""The Owners who administer a scope *across the subtree* (§22.8 / §22.11) —
|
||||
the recipients of a request-to-join, aggregated upward so the request reaches
|
||||
everyone who could grant it. For a `collection`: its collection-scope Owners,
|
||||
its project's Owners, the global Owners, and deployment owners/admins. For a
|
||||
`project`: its project-scope Owners plus global Owners and deployment
|
||||
owners/admins. (Mirrors the upward fold in `auth.can_invite_at_*`.)"""
|
||||
# Deployment owners/admins are global Owners by §B.1; explicit
|
||||
# scope_type='global' Owner grants join them.
|
||||
ids: set[int] = set(_admin_user_ids())
|
||||
for r in db.conn().execute(
|
||||
"SELECT user_id AS id FROM memberships WHERE scope_type = 'global' AND role = 'owner'"
|
||||
):
|
||||
ids.add(r["id"])
|
||||
|
||||
def _owners_at(stype: str, sid: str) -> None:
|
||||
for r in db.conn().execute(
|
||||
"SELECT user_id AS id FROM memberships "
|
||||
"WHERE scope_type = ? AND scope_id = ? AND role = 'owner'",
|
||||
(stype, sid),
|
||||
):
|
||||
ids.add(r["id"])
|
||||
|
||||
if scope_type == "collection":
|
||||
_owners_at("collection", scope_id)
|
||||
prow = db.conn().execute(
|
||||
"SELECT project_id FROM collections WHERE id = ?", (scope_id,)
|
||||
).fetchone()
|
||||
if prow and prow["project_id"]:
|
||||
_owners_at("project", prow["project_id"])
|
||||
elif scope_type == "project":
|
||||
_owners_at("project", scope_id)
|
||||
return ids
|
||||
|
||||
|
||||
def _proposer_user_id(rfc_slug: str) -> set[int]:
|
||||
row = db.conn().execute(
|
||||
"""
|
||||
@@ -859,6 +1025,36 @@ def render_summary(event_kind: str, actor_display: str | None, rfc_title: str |
|
||||
return f"{actor} accepted your request to contribute to {title} — check your email to accept the invitation."
|
||||
if event_kind == "contribution_request_declined":
|
||||
return f"{actor} declined your request to contribute to {title}."
|
||||
if event_kind == "scope_role_granted":
|
||||
# §22 S4 (C.2): names the role and the scope (the project, and the
|
||||
# collection when collection-scoped) per "a §15 notification naming the
|
||||
# project and role".
|
||||
role_label = "Owner" if extras.get("role") == "owner" else "RFC Contributor"
|
||||
project_label = extras.get("project_name") or extras.get("project_id") or "a project"
|
||||
scope_type = extras.get("scope_type")
|
||||
if scope_type == "collection":
|
||||
col_label = extras.get("collection_name") or extras.get("scope_id") or "a collection"
|
||||
return f"{actor} granted you {role_label} on collection {project_label}/{col_label}."
|
||||
if scope_type == "global":
|
||||
return f"{actor} granted you {role_label} across the whole deployment."
|
||||
return f"{actor} granted you {role_label} on project {project_label}."
|
||||
if event_kind == "join_request_on_scope":
|
||||
# §22.8: owner-facing, actionable. Names who wants in, where, and as
|
||||
# what; the inbox row renders Accept/Decline beneath this line.
|
||||
role_label = "Owner" if extras.get("requested_role") == "owner" else "RFC Contributor"
|
||||
scope_type = extras.get("scope_type")
|
||||
scope_label = extras.get("scope_name") or extras.get("scope_id") or "a scope"
|
||||
where = (
|
||||
f"collection {scope_label}" if scope_type == "collection" else f"project {scope_label}"
|
||||
)
|
||||
return f"{actor} asked to join {where} as {role_label}."
|
||||
if event_kind == "join_request_accepted":
|
||||
role_label = "Owner" if extras.get("granted_role") == "owner" else "RFC Contributor"
|
||||
scope_label = extras.get("scope_name") or extras.get("scope_id") or "the scope"
|
||||
return f"{actor} accepted your request to join {scope_label} — you're in as {role_label}."
|
||||
if event_kind == "join_request_declined":
|
||||
scope_label = extras.get("scope_name") or extras.get("scope_id") or "the scope"
|
||||
return f"{actor} declined your request to join {scope_label}."
|
||||
if event_kind == "new_beta_request":
|
||||
# v0.9.0: framework-scoped, not RFC-scoped. The actor (the
|
||||
# requester) and the captured full name + email read as
|
||||
|
||||
@@ -92,6 +92,95 @@ def restamp_default_project(config: Config) -> None:
|
||||
DEFAULT_PROJECT_ID, target, len(pid_tables))
|
||||
|
||||
|
||||
def reconcile_default_collection_id(config: Config) -> None:
|
||||
"""Heal the §22 migration-029 vs registry-mirror divergence for the default
|
||||
project's collection id on a multi-project deployment.
|
||||
|
||||
Migration 029 seeds each project's default collection id as the literal
|
||||
'default' only when the DB holds a single project at migration time; with
|
||||
≥2 projects it falls back to the *project id* (avoiding a PK collision —
|
||||
029 can't read DEFAULT_PROJECT_ID, there is no env in SQL). But the registry
|
||||
mirror (`registry._default_collection_id`) expects the deployment's default
|
||||
project to own the collection id 'default'. On an upgrade whose DB already
|
||||
held ≥2 projects when 029 ran, the default project's collection is therefore
|
||||
named after the project (e.g. 'ohm'), and the next mirror would INSERT a
|
||||
second, empty 'default' collection instead of merging — duplicating the
|
||||
default corpus and orphaning the entries (which point at 'ohm').
|
||||
|
||||
This is the collection-grain twin of `restamp_default_project`. Run at
|
||||
startup BEFORE the registry mirror so the canonical 'default' collection
|
||||
already exists when the mirror upserts (merge, not duplicate). Renames the
|
||||
divergent collection's id to 'default' and cascades `collection_id` across
|
||||
every collection-keyed table, with FK enforcement off for the atomic rename
|
||||
and a `foreign_key_check` backstop before commit. Idempotent; a no-op on
|
||||
fresh / single-project / already-aligned deployments.
|
||||
"""
|
||||
from .collections import DEFAULT_COLLECTION_ID
|
||||
|
||||
target = resolved_default_id(config)
|
||||
if target == DEFAULT_COLLECTION_ID: # default project already owns 'default'
|
||||
return
|
||||
conn = db.conn()
|
||||
# The 029 ≥2-projects seed names the default project's collection after the
|
||||
# project itself; the canonical id the mirror expects is 'default'.
|
||||
divergent = conn.execute(
|
||||
"SELECT 1 FROM collections WHERE id = ? AND project_id = ? LIMIT 1",
|
||||
(target, target),
|
||||
).fetchone()
|
||||
if not divergent:
|
||||
return
|
||||
if conn.execute(
|
||||
"SELECT 1 FROM collections WHERE id = ? LIMIT 1", (DEFAULT_COLLECTION_ID,)
|
||||
).fetchone():
|
||||
# A 'default' collection already exists (e.g. a prior buggy mirror left a
|
||||
# duplicate). Don't auto-merge data — that needs care; leave both for
|
||||
# operator cleanup and log loudly.
|
||||
log.warning(
|
||||
"reconcile: default project %r owns both a %r and a 'default' "
|
||||
"collection; skipping auto-rename (manual merge required)",
|
||||
target, target,
|
||||
)
|
||||
return
|
||||
|
||||
cid_tables = [
|
||||
t["name"]
|
||||
for t in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
if any(c["name"] == "collection_id"
|
||||
for c in conn.execute(f"PRAGMA table_info({t['name']})"))
|
||||
]
|
||||
conn.execute("PRAGMA foreign_keys = OFF")
|
||||
try:
|
||||
conn.execute("BEGIN")
|
||||
conn.execute(
|
||||
"UPDATE collections SET id = ? WHERE id = ?",
|
||||
(DEFAULT_COLLECTION_ID, target),
|
||||
)
|
||||
for t in cid_tables:
|
||||
conn.execute(
|
||||
f"UPDATE {t} SET collection_id = ? WHERE collection_id = ?",
|
||||
(DEFAULT_COLLECTION_ID, target),
|
||||
)
|
||||
violations = conn.execute("PRAGMA foreign_key_check").fetchall()
|
||||
if violations:
|
||||
conn.execute("ROLLBACK")
|
||||
raise RuntimeError(
|
||||
f"reconcile left foreign-key violations: {[tuple(v) for v in violations]}"
|
||||
)
|
||||
conn.execute("COMMIT")
|
||||
except Exception:
|
||||
try:
|
||||
conn.execute("ROLLBACK")
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
finally:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
log.info(
|
||||
"reconcile: renamed default-project collection %r -> 'default' across %d tables",
|
||||
target, len(cid_tables),
|
||||
)
|
||||
|
||||
|
||||
def default_content_repo(config: Config) -> str | None:
|
||||
"""The content repo the single-corpus mirror reads, from the default
|
||||
project's row (filled by the registry mirror). Replaces the retired
|
||||
|
||||
@@ -19,11 +19,27 @@ lockouts). Both layers run together.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
|
||||
|
||||
def _max_events(env_name: str, default: int) -> int:
|
||||
"""Per-limiter budget, overridable via env (e.g. a test/PPE stack that
|
||||
drives the auth endpoints repeatedly from one IP). Production leaves these
|
||||
unset and gets the secure defaults below. A non-positive / unparseable
|
||||
value falls back to the default."""
|
||||
raw = os.environ.get(env_name, "").strip()
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
n = int(raw)
|
||||
except ValueError:
|
||||
return default
|
||||
return n if n > 0 else default
|
||||
|
||||
|
||||
class SlidingWindowLimiter:
|
||||
"""Allow at most `max_events` per `window_seconds` per key.
|
||||
|
||||
@@ -66,12 +82,15 @@ class SlidingWindowLimiter:
|
||||
# * verify: 10 attempts / 5 min / IP across the auth verify surfaces.
|
||||
# * otc request: 5 sends / 5 min / IP (Turnstile is the primary gate;
|
||||
# this is defense in depth against a solved-challenge replay loop).
|
||||
verify_limiter = SlidingWindowLimiter(max_events=10, window_seconds=300)
|
||||
otc_request_limiter = SlidingWindowLimiter(max_events=5, window_seconds=300)
|
||||
verify_limiter = SlidingWindowLimiter(
|
||||
max_events=_max_events("RATELIMIT_VERIFY_MAX", 10), window_seconds=300)
|
||||
otc_request_limiter = SlidingWindowLimiter(
|
||||
max_events=_max_events("RATELIMIT_OTC_REQUEST_MAX", 5), window_seconds=300)
|
||||
# /auth/passcode/check is an anonymous has-passcode oracle (audit 0026 L3).
|
||||
# It's a legitimate Login-flow affordance, so the budget is generous —
|
||||
# enough for a human typing emails, tight enough to stop bulk scraping.
|
||||
check_limiter = SlidingWindowLimiter(max_events=30, window_seconds=300)
|
||||
check_limiter = SlidingWindowLimiter(
|
||||
max_events=_max_events("RATELIMIT_CHECK_MAX", 30), window_seconds=300)
|
||||
|
||||
|
||||
def _reset_all_for_tests() -> None:
|
||||
|
||||
+24
-4
@@ -18,6 +18,7 @@ from dataclasses import dataclass, field
|
||||
import yaml
|
||||
|
||||
from . import db
|
||||
from . import metadata_schema
|
||||
from .config import Config
|
||||
from .gitea import Gitea
|
||||
|
||||
@@ -64,6 +65,7 @@ class CollectionEntry:
|
||||
visibility: str | None
|
||||
initial_state: str
|
||||
name: str | None
|
||||
config: dict = field(default_factory=dict) # §22.12 enabled_models
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -149,7 +151,23 @@ def parse_collection_manifest(text: str) -> CollectionEntry:
|
||||
raise RegistryError(f"collection has invalid initial_state {initial_state!r}")
|
||||
name = raw.get("name")
|
||||
name = str(name).strip() if name else None
|
||||
return CollectionEntry(ctype, vis, initial_state, name)
|
||||
# §22.12: an optional per-collection enabled_models list that narrows the
|
||||
# project's universe. Absent → no narrowing (inherit). Present (incl. empty)
|
||||
# → narrowing applies; [] opts the collection out of AI.
|
||||
cfg: dict = {}
|
||||
if raw.get("enabled_models") is not None:
|
||||
em = raw["enabled_models"]
|
||||
if not isinstance(em, list):
|
||||
raise RegistryError("collection enabled_models must be a list")
|
||||
cfg["enabled_models"] = [str(m) for m in em]
|
||||
# §22.4a SLICE-2: the collection's metadata field schema. Parsed leniently —
|
||||
# a bad field def is skipped with a warning, never fatal (INV-3), so a typo
|
||||
# in one field can't drop the whole collection from the mirror. Stored only
|
||||
# when at least one valid field survives.
|
||||
fields = metadata_schema.parse_fields(raw.get("fields"))
|
||||
if fields:
|
||||
cfg["fields"] = fields
|
||||
return CollectionEntry(ctype, vis, initial_state, name, cfg)
|
||||
|
||||
|
||||
def _default_collection_id(project_id: str, default_id: str) -> str:
|
||||
@@ -268,17 +286,19 @@ def _upsert_named_collection(
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO collections
|
||||
(id, project_id, type, subfolder, initial_state, visibility, name, registry_sha, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
(id, project_id, type, subfolder, initial_state, visibility, name, config_json, registry_sha, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
project_id = excluded.project_id,
|
||||
initial_state = excluded.initial_state,
|
||||
visibility = excluded.visibility,
|
||||
name = excluded.name,
|
||||
config_json = excluded.config_json,
|
||||
registry_sha = excluded.registry_sha,
|
||||
updated_at = datetime('now')
|
||||
""",
|
||||
(subdir, proj.id, ce.type, subdir, ce.initial_state, visibility, ce.name, sha),
|
||||
(subdir, proj.id, ce.type, subdir, ce.initial_state, visibility, ce.name,
|
||||
json.dumps(ce.config), sha),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -26,6 +26,53 @@
|
||||
-- they carry a project-grain tag, untouched in S1. See
|
||||
-- docs/design/2026-06-05-three-tier-projects-collections.md §A.6 / Part E.
|
||||
|
||||
-- ── §22.13 repair: re-stamp stale satellite project_id before rekeying ──────
|
||||
-- The §22.13 default→ohm re-stamp (v0.39.0, `projects.restamp_default_project`)
|
||||
-- updated `cached_rfcs.project_id` but NOT the entry-satellite tables, leaving
|
||||
-- rows with a stale `project_id` (e.g. 'default') that the per-project collection
|
||||
-- backfill below cannot map — the subquery returns NULL and the NOT NULL rebuild
|
||||
-- fails (`cached_branches__new.collection_id`). Before rebuilding, re-derive each
|
||||
-- satellite's `project_id` from its entry (`cached_rfcs`, joined by slug — slugs
|
||||
-- are unique per collection and, pre-rebuild, globally), and drop rows whose
|
||||
-- entry no longer exists (stale cache; the `cached_*` tables are rebuildable from
|
||||
-- gitea). On a clean/fresh deployment every satellite is empty or already
|
||||
-- consistent, so this whole block is a no-op. (Discovered on the OHM data:
|
||||
-- ~1.3k `cached_branches` rows stranded at project_id='default'.)
|
||||
-- First drop stale rows that DUPLICATE an already-correctly-stamped row (the same
|
||||
-- branch cached under both the stale and the real project_id) — re-stamping them
|
||||
-- would collide on the (project_id, rfc_slug, branch_name) key. The correctly-
|
||||
-- stamped copy is kept (it carries the current head_sha / visibility). Only the
|
||||
-- branch-keyed tables can hold such a pair; the others key on (rfc_slug,user_id)
|
||||
-- /(scope,pr_number) and have no stale data here, so they need no dedup.
|
||||
DELETE FROM cached_branches WHERE project_id NOT IN (SELECT id FROM projects)
|
||||
AND EXISTS (SELECT 1 FROM cached_branches o WHERE o.rfc_slug = cached_branches.rfc_slug AND o.branch_name = cached_branches.branch_name AND o.project_id IN (SELECT id FROM projects));
|
||||
DELETE FROM branch_visibility WHERE project_id NOT IN (SELECT id FROM projects)
|
||||
AND EXISTS (SELECT 1 FROM branch_visibility o WHERE o.rfc_slug = branch_visibility.rfc_slug AND o.branch_name = branch_visibility.branch_name AND o.project_id IN (SELECT id FROM projects));
|
||||
UPDATE rfc_invitations SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = rfc_invitations.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
|
||||
DELETE FROM rfc_invitations WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
|
||||
UPDATE cached_branches SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = cached_branches.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
|
||||
DELETE FROM cached_branches WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
|
||||
UPDATE branch_visibility SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = branch_visibility.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
|
||||
DELETE FROM branch_visibility WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
|
||||
UPDATE branch_contribute_grants SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = branch_contribute_grants.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
|
||||
DELETE FROM branch_contribute_grants WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
|
||||
UPDATE stars SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = stars.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
|
||||
DELETE FROM stars WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
|
||||
UPDATE watches SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = watches.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
|
||||
DELETE FROM watches WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
|
||||
UPDATE pr_seen SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = pr_seen.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
|
||||
DELETE FROM pr_seen WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
|
||||
UPDATE branch_chat_seen SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = branch_chat_seen.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
|
||||
DELETE FROM branch_chat_seen WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
|
||||
UPDATE funder_consents SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = funder_consents.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
|
||||
DELETE FROM funder_consents WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
|
||||
UPDATE rfc_collaborators SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = rfc_collaborators.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
|
||||
DELETE FROM rfc_collaborators WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
|
||||
UPDATE contribution_requests SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = contribution_requests.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
|
||||
DELETE FROM contribution_requests WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
|
||||
UPDATE proposed_use_cases SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = proposed_use_cases.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
|
||||
DELETE FROM proposed_use_cases WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
|
||||
|
||||
-- ── collections: the new typed-corpus grain beneath projects ───────────────
|
||||
CREATE TABLE collections (
|
||||
id TEXT NOT NULL,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- §22.12 S6 — per-collection model universe.
|
||||
--
|
||||
-- A collection's `.collection.yaml` may carry an `enabled_models` list that
|
||||
-- NARROWS its project's universe (which narrows the deployment ENABLED_MODELS).
|
||||
-- Mirrored into a `config_json` blob on the collection row, paralleling
|
||||
-- `projects.config_json` (which already holds the project's enabled_models +
|
||||
-- theme). Additive only — no rebuild. NULL means "no per-collection narrowing;
|
||||
-- inherit the project's universe."
|
||||
ALTER TABLE collections ADD COLUMN config_json TEXT;
|
||||
@@ -0,0 +1,58 @@
|
||||
-- §22.8 S6 — request-to-join a scope + the cross-collection inbox.
|
||||
--
|
||||
-- A gated project or collection is invisible to non-members (§22.5), so a user
|
||||
-- who knows a scope exists can ask to join it: they name a desired role and the
|
||||
-- request is recorded here, then fanned out to that scope's Owners *across the
|
||||
-- subtree* (a collection request reaches the collection's Owners, its project's
|
||||
-- Owners, and global Owners — the cross-collection inbox, §22.11). An Owner
|
||||
-- accepts (which writes the `memberships` row via memberships.grant) or declines;
|
||||
-- the requester is §15-notified of the decision either way.
|
||||
--
|
||||
-- This mirrors `contribution_requests` (migration 024) but at the scope grain
|
||||
-- instead of the per-RFC grain: the target is a `(scope_type, scope_id)` pair
|
||||
-- (matching the `memberships` scope vocabulary, minus 'global' — joining is for a
|
||||
-- project or collection a user discovers, not the deployment), and accept grants
|
||||
-- a scope role rather than minting an RFC invitation.
|
||||
--
|
||||
-- The request row is the persistent record; the inbox notification is the
|
||||
-- owner-facing actionable surface keyed back to it via `notification_id`.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS join_requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
-- The target scope. 'global' is intentionally excluded: the deployment is
|
||||
-- not a thing one "discovers and joins" (§22.8 names a project/collection).
|
||||
scope_type TEXT NOT NULL
|
||||
CHECK (scope_type IN ('project', 'collection')),
|
||||
scope_id TEXT NOT NULL,
|
||||
requester_user_id INTEGER NOT NULL
|
||||
REFERENCES users(id) ON DELETE CASCADE,
|
||||
-- The role the requester is asking for ({owner, contributor}, the §22.6
|
||||
-- unified vocabulary). The accepting Owner may grant this or a narrower role.
|
||||
requested_role TEXT NOT NULL
|
||||
CHECK (requested_role IN ('owner', 'contributor')),
|
||||
-- Optional free text — "who I am / why I want in". Bounded by the API layer.
|
||||
message TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'accepted', 'declined')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
decided_at TEXT,
|
||||
decided_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
-- The role actually granted on accept (may differ from requested_role if the
|
||||
-- Owner narrowed it); NULL until accepted.
|
||||
granted_role TEXT CHECK (granted_role IN ('owner', 'contributor')),
|
||||
-- The owner-facing notification row that carries the Accept/Decline action.
|
||||
notification_id INTEGER REFERENCES notifications(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_join_requests_scope
|
||||
ON join_requests(scope_type, scope_id, status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_join_requests_requester
|
||||
ON join_requests(requester_user_id, status);
|
||||
|
||||
-- At most one open (pending) request per (scope, requester): a second ask while
|
||||
-- one is still pending is a 409, not a duplicate row. A decided request
|
||||
-- (accepted/declined) does not block a fresh ask later.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_join_requests_one_open
|
||||
ON join_requests(scope_type, scope_id, requester_user_id)
|
||||
WHERE status = 'pending';
|
||||
@@ -0,0 +1,8 @@
|
||||
-- §22.4a SLICE-1 — configurable collection metadata: malformed-sidecar flag.
|
||||
--
|
||||
-- The corpus mirror reads an entry's metadata from its `<slug>.meta.yaml`
|
||||
-- sidecar (dual-read: sidecar-else-legacy-frontmatter). A sidecar that does not
|
||||
-- parse as a YAML mapping never hard-fails the read (INV-3) — the entry still
|
||||
-- loads (from the legacy `.md` frontmatter if present) and this derived flag
|
||||
-- marks it so the catalog can surface it. Additive only — no rebuild. 0 = ok.
|
||||
ALTER TABLE cached_rfcs ADD COLUMN metadata_malformed INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- §22.4a SLICE-3 — configurable collection metadata: cache per-entry values.
|
||||
--
|
||||
-- Faceted filtering (§5.1) needs each entry's metadata values (priority, custom
|
||||
-- enum/tags fields) to compute facet counts and honour filter params. Today the
|
||||
-- mirror keeps only `tags_json` + the lifecycle columns and drops `Entry.extra`,
|
||||
-- so a declared field's values are unrecoverable. This column persists the full
|
||||
-- per-entry metadata mapping (`metadata.metadata_dict(entry)`, known keys +
|
||||
-- extra, never the body) as JSON, so `app/facets.py` can read any declared
|
||||
-- field uniformly. Additive + nullable — no rebuild. NULL = not yet re-ingested
|
||||
-- (the reconciler/webhook fills it on the next sweep) → that entry contributes
|
||||
-- no facet values until then. SLICE-4/5 edit panels read the same column.
|
||||
ALTER TABLE cached_rfcs ADD COLUMN meta_json TEXT;
|
||||
@@ -0,0 +1,62 @@
|
||||
"""SLICE-4 — bot multi-file commit + PR primitives."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from app import gitea as gitea_mod, metadata
|
||||
from app.bot import Actor, Bot
|
||||
from app.config import load_config
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
LEGACY = "---\nslug: alpha\ntitle: Alpha\nstate: active\ntags:\n- one\n---\n\nBody.\n"
|
||||
|
||||
|
||||
def _actor():
|
||||
return Actor(user_id=1, gitea_login="ben.stull", display_name="Ben", email="ben@x.io")
|
||||
|
||||
|
||||
def test_commit_entry_files_direct_to_main(app_with_fake_gitea):
|
||||
_app, fake = app_with_fake_gitea
|
||||
gitea = gitea_mod.Gitea(load_config())
|
||||
bot = Bot(gitea)
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")] = {
|
||||
"content": LEGACY, "sha": "s1"}
|
||||
st = asyncio.run(metadata.read_entry_from_git(gitea, "wiggleverse", "meta", "rfcs/alpha.md"))
|
||||
e2 = metadata.apply_values(st.entry, {"tags": ["two"]})
|
||||
ops = metadata.write_entry_files("rfcs/alpha.md", e2, st)
|
||||
asyncio.run(bot.commit_entry_files(
|
||||
_actor(), org="wiggleverse", repo="meta", files=ops,
|
||||
message="Edit metadata", branch="main"))
|
||||
sc = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml")]["content"]
|
||||
assert "two" in sc
|
||||
# .md is now body-only
|
||||
assert "---" not in fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")]["content"]
|
||||
|
||||
|
||||
def test_open_entry_pr_commits_on_branch_and_opens_pr(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
gitea = gitea_mod.Gitea(load_config())
|
||||
bot = Bot(gitea)
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")] = {
|
||||
"content": LEGACY, "sha": "s1"}
|
||||
with TestClient(app): # lifespan inits the DB
|
||||
provision_user_row(user_id=1, login="ben.stull", role="owner")
|
||||
st = asyncio.run(metadata.read_entry_from_git(gitea, "wiggleverse", "meta", "rfcs/alpha.md"))
|
||||
e2 = metadata.apply_values(st.entry, {"tags": ["two"]})
|
||||
ops = metadata.write_entry_files("rfcs/alpha.md", e2, st)
|
||||
pr = asyncio.run(bot.open_entry_pr(
|
||||
_actor(), org="wiggleverse", repo="meta", slug="alpha", files=ops,
|
||||
pr_title="Metadata: Alpha", pr_description="edit", branch_prefix="metadata"))
|
||||
assert pr["number"] >= 1
|
||||
head = pr["head"]["ref"]
|
||||
assert head.startswith("metadata-alpha-")
|
||||
# committed on the branch, main untouched
|
||||
assert ("wiggleverse", "meta", head, "rfcs/alpha.meta.yaml") in fake.files
|
||||
assert ("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml") not in fake.files
|
||||
@@ -2,6 +2,7 @@
|
||||
subfolder_of."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
@@ -62,3 +63,23 @@ def test_get_collection_and_subfolder():
|
||||
assert collections_mod.subfolder_of("features") == "features"
|
||||
assert collections_mod.subfolder_of("default") == ""
|
||||
assert collections_mod.get_collection("nope") is None
|
||||
|
||||
|
||||
# ---- §22.4a SLICE-2: field schema served on the collection ----
|
||||
|
||||
def test_get_collection_fields_none_when_unset():
|
||||
# INV-5: a collection with no `fields:` exposes fields=None (the default
|
||||
# `document` collection sees zero change).
|
||||
_db()
|
||||
_seed()
|
||||
assert collections_mod.get_collection("features")["fields"] is None
|
||||
|
||||
|
||||
def test_get_collection_exposes_field_schema():
|
||||
_db()
|
||||
_seed()
|
||||
schema = {"priority": {"type": "enum", "values": ["P0", "P1"]}}
|
||||
db.conn().execute(
|
||||
"UPDATE collections SET config_json = ? WHERE id = 'features'",
|
||||
(json.dumps({"fields": schema}),))
|
||||
assert collections_mod.get_collection("features")["fields"] == schema
|
||||
|
||||
@@ -61,6 +61,36 @@ def test_parse_collection_manifest_rejects_bad_visibility():
|
||||
registry.parse_collection_manifest("type: bdd\nvisibility: nope\n")
|
||||
|
||||
|
||||
# ---- §22.4a SLICE-2: a `fields:` block flows into the collection config ----
|
||||
|
||||
def test_parse_collection_manifest_reads_field_schema():
|
||||
doc = registry.parse_collection_manifest(
|
||||
"type: bdd\n"
|
||||
"fields:\n"
|
||||
" priority:\n"
|
||||
" type: enum\n"
|
||||
" values: [P0, P1, P2]\n"
|
||||
" tags:\n"
|
||||
" type: tags\n"
|
||||
)
|
||||
assert doc.config["fields"] == {
|
||||
"priority": {"type": "enum", "values": ["P0", "P1", "P2"]},
|
||||
"tags": {"type": "tags"},
|
||||
}
|
||||
|
||||
|
||||
def test_parse_collection_manifest_lenient_on_bad_field():
|
||||
# A bad field def is skipped (INV-3), the manifest still parses, and a
|
||||
# manifest with no surviving fields carries no `fields` config key at all.
|
||||
doc = registry.parse_collection_manifest(
|
||||
"type: bdd\n"
|
||||
"fields:\n"
|
||||
" broken:\n"
|
||||
" type: ref\n"
|
||||
)
|
||||
assert "fields" not in doc.config
|
||||
|
||||
|
||||
# --- mirror discovery ---------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""§22 S5 — create-project vertical: a global Owner POSTs `/api/projects`, the
|
||||
bot provisions a Gitea content repo + commits the project to `projects.yaml`, and
|
||||
the registry mirror upserts the `projects` + default `collections` rows (registry
|
||||
stays the source of truth). Plus the deployment-directory empty-state signals
|
||||
(`viewer.can_create_project`, `default_project_readable`) that drive C3.1/C3.2.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import db
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
|
||||
)
|
||||
|
||||
|
||||
def test_create_project_provisions_repo_commits_registry_and_mirrors(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
|
||||
role="owner", email="ben@test")
|
||||
r = client.post("/api/projects",
|
||||
json={"project_id": "acme", "name": "Acme", "type": "bdd",
|
||||
"visibility": "public"})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["id"] == "acme"
|
||||
assert body["type"] == "bdd"
|
||||
assert body["visibility"] == "public"
|
||||
|
||||
# The bot provisioned the content repo (default name <id>-content) and
|
||||
# seeded a README so `main` exists.
|
||||
assert ("wiggleverse", "acme-content") in fake.repos
|
||||
readme = fake.files.get(("wiggleverse", "acme-content", "main", "README.md"))
|
||||
assert readme is not None and "acme" in readme["content"]
|
||||
|
||||
# The bot committed the new project into projects.yaml.
|
||||
reg = fake.files.get(("wiggleverse", "registry", "main", "projects.yaml"))
|
||||
assert reg is not None
|
||||
assert "id: acme" in reg["content"]
|
||||
assert "content_repo: acme-content" in reg["content"]
|
||||
|
||||
# The registry refresh mirrored a projects row + its default collection.
|
||||
prow = db.conn().execute(
|
||||
"SELECT name, content_repo, visibility FROM projects WHERE id='acme'"
|
||||
).fetchone()
|
||||
assert (prow["name"], prow["content_repo"], prow["visibility"]) == (
|
||||
"Acme", "acme-content", "public")
|
||||
crow = db.conn().execute(
|
||||
"SELECT type, project_id, subfolder FROM collections WHERE id='acme'"
|
||||
).fetchone()
|
||||
assert (crow["type"], crow["project_id"], crow["subfolder"]) == ("bdd", "acme", "")
|
||||
|
||||
# It is now visible in the deployment directory and readable.
|
||||
ids = {p["id"] for p in client.get("/api/deployment").json()["projects"]}
|
||||
assert "acme" in ids
|
||||
assert client.get("/api/projects/acme").status_code == 200
|
||||
|
||||
# An audit row records the structural action with the global Owner actor.
|
||||
act = db.conn().execute(
|
||||
"SELECT actor_user_id FROM actions WHERE action_kind='create_project'"
|
||||
).fetchone()
|
||||
assert act is not None and act["actor_user_id"] == 1
|
||||
|
||||
|
||||
def test_create_project_custom_content_repo_name(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
|
||||
role="owner", email="ben@test")
|
||||
r = client.post("/api/projects",
|
||||
json={"project_id": "beta", "name": "Beta", "type": "document",
|
||||
"content_repo": "beta-corpus"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert ("wiggleverse", "beta-corpus") in fake.repos
|
||||
prow = db.conn().execute(
|
||||
"SELECT content_repo FROM projects WHERE id='beta'"
|
||||
).fetchone()
|
||||
assert prow["content_repo"] == "beta-corpus"
|
||||
|
||||
|
||||
def test_create_project_requires_global_owner(app_with_fake_gitea):
|
||||
# A plain deployment contributor is not a global Owner (C: + New project is a
|
||||
# global-Owner action), even though they may create collections.
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice",
|
||||
role="contributor", email="alice@test")
|
||||
r = client.post("/api/projects",
|
||||
json={"project_id": "x", "name": "X", "type": "bdd"})
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_create_project_global_owner_grant_permitted(app_with_fake_gitea):
|
||||
# An explicit global-scope Owner grant (not a deployment owner/admin) may
|
||||
# create projects.
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=3, login="gina", role="contributor")
|
||||
db.conn().execute(
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('global', '*', 3, 'owner')"
|
||||
)
|
||||
sign_in_as(client, user_id=3, gitea_login="gina", display_name="Gina",
|
||||
role="contributor", email="gina@test")
|
||||
r = client.post("/api/projects",
|
||||
json={"project_id": "gproj", "name": "G", "type": "document"})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_create_project_anonymous_rejected(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
r = client.post("/api/projects",
|
||||
json={"project_id": "x", "name": "X", "type": "bdd"})
|
||||
assert r.status_code in (401, 403)
|
||||
|
||||
|
||||
def test_create_project_rejects_duplicate(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
|
||||
role="owner", email="ben@test")
|
||||
ok = client.post("/api/projects",
|
||||
json={"project_id": "acme", "name": "Acme", "type": "bdd"})
|
||||
assert ok.status_code == 200, ok.text
|
||||
dup = client.post("/api/projects",
|
||||
json={"project_id": "acme", "name": "Acme 2", "type": "bdd"})
|
||||
assert dup.status_code == 409
|
||||
|
||||
|
||||
def test_create_project_rejects_reserved_default_id(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
|
||||
role="owner", email="ben@test")
|
||||
r = client.post("/api/projects",
|
||||
json={"project_id": "default", "name": "X", "type": "bdd"})
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_create_project_rejects_bad_type(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
|
||||
role="owner", email="ben@test")
|
||||
r = client.post("/api/projects",
|
||||
json={"project_id": "x", "name": "X", "type": "nonsense"})
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
# --- C3.1 / C3.2: deployment-directory empty-state signals ------------------
|
||||
|
||||
|
||||
def test_deployment_owner_sees_create_project_capability(app_with_fake_gitea):
|
||||
# C3.1: a global Owner is offered the create-project action.
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
|
||||
body = client.get("/api/deployment").json()
|
||||
assert body["viewer"]["can_create_project"] is True
|
||||
|
||||
|
||||
def test_deployment_non_owner_no_create_capability(app_with_fake_gitea):
|
||||
# C3.2: a granted account with no roles is not offered create-project.
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="vee", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="vee", display_name="Vee", role="contributor")
|
||||
body = client.get("/api/deployment").json()
|
||||
assert body["viewer"]["can_create_project"] is False
|
||||
|
||||
|
||||
def test_deployment_default_readable_when_default_is_public(app_with_fake_gitea):
|
||||
# The seeded default project is public → readable → the N=1 redirect target
|
||||
# is valid (land-in-corpus preserved).
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
body = client.get("/api/deployment").json()
|
||||
assert body["default_project_readable"] is True
|
||||
|
||||
|
||||
def test_deployment_default_not_readable_when_only_gated(app_with_fake_gitea):
|
||||
# C3.2: the only project is gated; a granted non-member sees no visible
|
||||
# projects AND default_project_readable False → the frontend renders the
|
||||
# empty directory (no 404 bounce).
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
db.conn().execute("UPDATE projects SET visibility='gated' WHERE id='default'")
|
||||
db.conn().execute("UPDATE collections SET visibility='gated' WHERE id='default'")
|
||||
provision_user_row(user_id=2, login="vee", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="vee", display_name="Vee", role="contributor")
|
||||
body = client.get("/api/deployment").json()
|
||||
assert body["projects"] == []
|
||||
assert body["default_project_readable"] is False
|
||||
@@ -0,0 +1,165 @@
|
||||
"""End-to-end integration tests for the deployed-environment E2E
|
||||
test-auth path (`POST /auth/test/login`).
|
||||
|
||||
This endpoint is a **deliberately gated auth shortcut** for running the
|
||||
Playwright E2E suite against a *deployed* environment (PPE) that has no
|
||||
Mailpit OTC sink and no direct SQLite access to inject an owner row —
|
||||
the two scaffolds the Tier-1 stack relies on. It mints an authenticated
|
||||
**owner** session for a single, pre-configured test identity, but ONLY
|
||||
when the deployment has explicitly opted in by setting BOTH
|
||||
`E2E_TEST_AUTH_SECRET` and `E2E_TEST_AUTH_EMAIL`. It is fail-closed:
|
||||
|
||||
* Off by default — with neither (or only one) env var set, the route
|
||||
is invisible (404), so a production deployment that never sets them
|
||||
cannot be coaxed into minting a session.
|
||||
* Even when enabled, it requires the caller to present the shared
|
||||
secret in the `X-Test-Auth-Secret` header (constant-time compare),
|
||||
and it will only mint a session for the one configured email — any
|
||||
other address is refused (403). So the blast radius of an enabled
|
||||
PPE is a single throwaway owner identity, and the secret is the
|
||||
trust boundary.
|
||||
|
||||
The tests below pin every branch of that gate.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
SECRET = "ppe-e2e-shared-secret-value"
|
||||
EMAIL = "e2e-owner@example.test"
|
||||
|
||||
|
||||
def test_test_login_is_404_when_disabled(app_with_fake_gitea):
|
||||
"""Neither env var set (the default, incl. production) → the route
|
||||
does not exist."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
r = client.post(
|
||||
"/auth/test/login",
|
||||
json={"email": EMAIL},
|
||||
headers={"X-Test-Auth-Secret": SECRET},
|
||||
)
|
||||
assert r.status_code == 404, r.text
|
||||
|
||||
|
||||
def test_test_login_is_404_when_only_email_is_set(app_with_fake_gitea, monkeypatch):
|
||||
"""Half-configured (email but no secret) must NOT open the route —
|
||||
a framework auth shortcut gated only by a known email would be far
|
||||
too weak."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
monkeypatch.setenv("E2E_TEST_AUTH_EMAIL", EMAIL)
|
||||
monkeypatch.delenv("E2E_TEST_AUTH_SECRET", raising=False)
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
r = client.post(
|
||||
"/auth/test/login",
|
||||
json={"email": EMAIL},
|
||||
headers={"X-Test-Auth-Secret": SECRET},
|
||||
)
|
||||
assert r.status_code == 404, r.text
|
||||
|
||||
|
||||
def test_test_login_refuses_wrong_secret(app_with_fake_gitea, monkeypatch):
|
||||
"""Enabled, but a bad/absent secret → 404 (don't advertise the
|
||||
route's existence to an unauthenticated caller)."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
monkeypatch.setenv("E2E_TEST_AUTH_SECRET", SECRET)
|
||||
monkeypatch.setenv("E2E_TEST_AUTH_EMAIL", EMAIL)
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
# Wrong secret.
|
||||
r = client.post(
|
||||
"/auth/test/login",
|
||||
json={"email": EMAIL},
|
||||
headers={"X-Test-Auth-Secret": "not-the-secret"},
|
||||
)
|
||||
assert r.status_code == 404, r.text
|
||||
# Absent secret.
|
||||
r = client.post("/auth/test/login", json={"email": EMAIL})
|
||||
assert r.status_code == 404, r.text
|
||||
|
||||
|
||||
def test_test_login_refuses_unconfigured_email(app_with_fake_gitea, monkeypatch):
|
||||
"""Right secret but an email other than the single configured
|
||||
identity → 403. Even a secret-bearer can only mint the one test
|
||||
owner."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
monkeypatch.setenv("E2E_TEST_AUTH_SECRET", SECRET)
|
||||
monkeypatch.setenv("E2E_TEST_AUTH_EMAIL", EMAIL)
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
r = client.post(
|
||||
"/auth/test/login",
|
||||
json={"email": "someone-else@example.test"},
|
||||
headers={"X-Test-Auth-Secret": SECRET},
|
||||
)
|
||||
assert r.status_code == 403, r.text
|
||||
|
||||
|
||||
def test_test_login_mints_owner_session(app_with_fake_gitea, monkeypatch):
|
||||
"""The happy path: right secret + configured email → an authenticated
|
||||
session whose user is a GRANTED OWNER (so the metadata write paths —
|
||||
SLICE-4 edit, SLICE-5 bulk — accept it), persisted on a fresh
|
||||
`users` row."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
monkeypatch.setenv("E2E_TEST_AUTH_SECRET", SECRET)
|
||||
monkeypatch.setenv("E2E_TEST_AUTH_EMAIL", EMAIL)
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
r = client.post(
|
||||
"/auth/test/login",
|
||||
json={"email": EMAIL},
|
||||
headers={"X-Test-Auth-Secret": SECRET},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# The session cookie now surfaces an authenticated owner.
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["authenticated"] is True
|
||||
assert me["user"]["email"] == EMAIL
|
||||
assert me["user"]["role"] == "owner"
|
||||
assert me["user"]["permission_state"] == "granted"
|
||||
|
||||
# Idempotent: a second login reuses the same row (still owner).
|
||||
r = client.post(
|
||||
"/auth/test/login",
|
||||
json={"email": EMAIL},
|
||||
headers={"X-Test-Auth-Secret": SECRET},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
from app import db
|
||||
rows = db.conn().execute(
|
||||
"SELECT role, permission_state FROM users WHERE email = ? COLLATE NOCASE",
|
||||
(EMAIL,),
|
||||
).fetchall()
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["role"] == "owner"
|
||||
assert rows[0]["permission_state"] == "granted"
|
||||
|
||||
|
||||
def test_test_login_is_case_insensitive_on_email(app_with_fake_gitea, monkeypatch):
|
||||
"""The configured-email check matches case-insensitively, mirroring
|
||||
how the rest of the auth stack treats email."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
monkeypatch.setenv("E2E_TEST_AUTH_SECRET", SECRET)
|
||||
monkeypatch.setenv("E2E_TEST_AUTH_EMAIL", EMAIL)
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
r = client.post(
|
||||
"/auth/test/login",
|
||||
json={"email": EMAIL.upper()},
|
||||
headers={"X-Test-Auth-Secret": SECRET},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
@@ -0,0 +1,87 @@
|
||||
"""§22.4a SLICE-3 — pure facet field-set + filter/count (PUC-3).
|
||||
|
||||
Per docs/design/2026-06-06-configurable-collection-metadata.md §5.1, §6.4.
|
||||
"""
|
||||
from app import facets
|
||||
|
||||
|
||||
PRIORITY = {"priority": {"type": "enum", "values": ["P0", "P1", "P2"]}}
|
||||
SCHEMA = {
|
||||
"priority": {"type": "enum", "values": ["P0", "P1", "P2"]},
|
||||
"tags": {"type": "tags"},
|
||||
"owner": {"type": "text"},
|
||||
}
|
||||
|
||||
|
||||
def _e(slug, state="active", malformed=False, **meta):
|
||||
return {"slug": slug, "state": state, "metadata_malformed": malformed, "meta": meta}
|
||||
|
||||
|
||||
def test_facet_fields_orders_declared_then_state_skips_text():
|
||||
# enum + tags in declaration order, text skipped, state appended last.
|
||||
assert facets.facet_fields(SCHEMA) == [
|
||||
("priority", "enum"), ("tags", "tags"), ("state", "enum")]
|
||||
|
||||
|
||||
def test_no_schema_yields_no_facets():
|
||||
# INV-5: a collection with no fields has no facets (frontend keeps chips).
|
||||
assert facets.facet_fields(None) == []
|
||||
assert facets.facet_fields({}) == []
|
||||
|
||||
|
||||
def test_filter_and_count_basic_counts():
|
||||
entries = [
|
||||
_e("a", priority="P0", tags=["checkout"]),
|
||||
_e("b", priority="P0", tags=["cart"]),
|
||||
_e("c", priority="P1", tags=["checkout", "cart"]),
|
||||
]
|
||||
items, fac = facets.filter_and_count(entries, SCHEMA, {})
|
||||
assert {i["slug"] for i in items} == {"a", "b", "c"}
|
||||
assert fac["priority"] == {"P0": 2, "P1": 1}
|
||||
assert fac["tags"] == {"checkout": 2, "cart": 2}
|
||||
assert fac["state"] == {"active": 3}
|
||||
|
||||
|
||||
def test_filter_compose_or_within_and_across():
|
||||
entries = [
|
||||
_e("a", priority="P0", tags=["checkout"]), # P0 + checkout
|
||||
_e("b", priority="P0", tags=["cart"]), # P0, no checkout
|
||||
_e("c", priority="P1", tags=["checkout"]), # checkout, not P0
|
||||
]
|
||||
# priority=P0 AND tags=checkout → only "a".
|
||||
items, _ = facets.filter_and_count(
|
||||
entries, SCHEMA, {"priority": {"P0"}, "tags": {"checkout"}})
|
||||
assert {i["slug"] for i in items} == {"a"}
|
||||
|
||||
|
||||
def test_drilldown_counts_exclude_own_field_selection():
|
||||
entries = [
|
||||
_e("a", priority="P0"),
|
||||
_e("b", priority="P1"),
|
||||
_e("c", priority="P1"),
|
||||
]
|
||||
# With P0 selected, the priority facet still counts P1 over the set that
|
||||
# ignores priority's own selection — so P1 stays switchable.
|
||||
_, fac = facets.filter_and_count(entries, SCHEMA, {"priority": {"P0"}})
|
||||
assert fac["priority"] == {"P0": 1, "P1": 2}
|
||||
|
||||
|
||||
def test_malformed_toggle_narrows_items_and_counts():
|
||||
entries = [
|
||||
_e("a", state="active", malformed=True, priority="P9"),
|
||||
_e("b", state="active", malformed=False, priority="P0"),
|
||||
]
|
||||
items, fac = facets.filter_and_count(entries, SCHEMA, {}, only_malformed=True)
|
||||
assert {i["slug"] for i in items} == {"a"}
|
||||
assert fac["priority"] == {"P9": 1}
|
||||
|
||||
|
||||
def test_missing_value_contributes_no_facet_value():
|
||||
entries = [_e("a", priority="P0"), _e("b")] # b has no priority
|
||||
_, fac = facets.filter_and_count(entries, SCHEMA, {})
|
||||
assert fac["priority"] == {"P0": 1}
|
||||
|
||||
|
||||
def test_allowed_filter_keys():
|
||||
assert facets.allowed_filter_keys(PRIORITY) == {"priority", "state",
|
||||
"unreviewed", "malformed"}
|
||||
@@ -0,0 +1,134 @@
|
||||
"""§22.4a SLICE-3 integration — faceted list endpoint (PUC-3, §6.4).
|
||||
|
||||
Through the real API: schema-declared facets, filter params (OR within / AND
|
||||
across), drill-down counts, malformed toggle, unknown-field 400, and meta_json
|
||||
persistence. Reuses the fake-Gitea harness from test_metadata_cache.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import yaml
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import cache, db, gitea as gitea_mod
|
||||
from app.config import load_config
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401 (fixtures)
|
||||
app_with_fake_gitea,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
# The fake-Gitea harness seeds a single project whose id is the literal
|
||||
# 'default' (see test_s1_collection_grain_vertical), served at
|
||||
# /api/projects/default/rfcs.
|
||||
PID = "default"
|
||||
|
||||
|
||||
def _refresh():
|
||||
cfg = load_config()
|
||||
asyncio.run(cache.refresh_meta_repo(cfg, gitea_mod.Gitea(cfg)))
|
||||
|
||||
|
||||
def _set_default_fields_schema(schema):
|
||||
db.conn().execute(
|
||||
"UPDATE collections SET config_json = ? WHERE id = 'default'",
|
||||
(json.dumps({"fields": schema}),))
|
||||
|
||||
|
||||
def _seed(fake, slug, *, state="active", **front):
|
||||
fm = {"slug": slug, "title": slug.title(), "state": state, **front}
|
||||
body = yaml.safe_dump(fm, sort_keys=False).strip()
|
||||
fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.md")] = {
|
||||
"content": f"---\n{body}\n---\n\nBody.\n", "sha": slug}
|
||||
|
||||
|
||||
def test_facets_and_counts_returned(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_set_default_fields_schema({
|
||||
"priority": {"type": "enum", "values": ["P0", "P1", "P2"]},
|
||||
"tags": {"type": "tags"},
|
||||
})
|
||||
_seed(fake, "a", priority="P0", tags=["checkout"])
|
||||
_seed(fake, "b", priority="P0", tags=["cart"])
|
||||
_seed(fake, "c", priority="P1", tags=["checkout", "cart"])
|
||||
_refresh()
|
||||
|
||||
res = client.get(f"/api/projects/{PID}/rfcs")
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert {i["slug"] for i in body["items"]} == {"a", "b", "c"}
|
||||
assert body["facets"]["priority"] == {"P0": 2, "P1": 1}
|
||||
assert body["facets"]["tags"] == {"checkout": 2, "cart": 2}
|
||||
assert body["facets"]["state"] == {"active": 3}
|
||||
|
||||
|
||||
def test_filter_params_compose(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_set_default_fields_schema({
|
||||
"priority": {"type": "enum", "values": ["P0", "P1"]},
|
||||
"tags": {"type": "tags"},
|
||||
})
|
||||
_seed(fake, "a", priority="P0", tags=["checkout"])
|
||||
_seed(fake, "b", priority="P0", tags=["cart"])
|
||||
_seed(fake, "c", priority="P1", tags=["checkout"])
|
||||
_refresh()
|
||||
|
||||
res = client.get(
|
||||
f"/api/projects/{PID}/rfcs",
|
||||
params={"priority": "P0", "tags": "checkout"})
|
||||
assert {i["slug"] for i in res.json()["items"]} == {"a"}
|
||||
|
||||
|
||||
def test_unknown_filter_field_400(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_set_default_fields_schema(
|
||||
{"priority": {"type": "enum", "values": ["P0"]}})
|
||||
_seed(fake, "a", priority="P0")
|
||||
_refresh()
|
||||
|
||||
res = client.get(f"/api/projects/{PID}/rfcs",
|
||||
params={"nonsense": "x"})
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
def test_malformed_toggle(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_set_default_fields_schema(
|
||||
{"priority": {"type": "enum", "values": ["P0", "P1"]}})
|
||||
_seed(fake, "good", priority="P0")
|
||||
_seed(fake, "bad", priority="P9") # not in values → malformed (INV-3)
|
||||
_refresh()
|
||||
|
||||
res = client.get(f"/api/projects/{PID}/rfcs",
|
||||
params={"malformed": "true"})
|
||||
slugs = {i["slug"] for i in res.json()["items"]}
|
||||
assert slugs == {"bad"}
|
||||
|
||||
|
||||
def test_no_schema_no_facets(app_with_fake_gitea):
|
||||
# INV-5: the default document collection (no fields) returns empty facets.
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed(fake, "plain", tags=["whatever"])
|
||||
_refresh()
|
||||
body = client.get(f"/api/projects/{PID}/rfcs").json()
|
||||
assert body["facets"] == {}
|
||||
|
||||
|
||||
def test_meta_json_persisted_at_ingest(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
_seed(fake, "withmeta", priority="P0", tags=["x"])
|
||||
_refresh()
|
||||
row = db.conn().execute(
|
||||
"SELECT meta_json FROM cached_rfcs WHERE slug = 'withmeta'"
|
||||
).fetchone()
|
||||
meta = json.loads(row["meta_json"])
|
||||
assert meta["priority"] == "P0"
|
||||
assert meta["tags"] == ["x"]
|
||||
@@ -48,6 +48,18 @@ PITCH = (
|
||||
)
|
||||
|
||||
|
||||
def _entry_from_git(fake, slug, branch="main"):
|
||||
"""§22.4a SLICE-4: read an entry's combined metadata+body from git via the
|
||||
dual-read parser — graduation/claim now write metadata to the sidecar and
|
||||
keep the body in the `.md`, so an Entry is reconstructed from both."""
|
||||
from app import metadata
|
||||
md = fake.files[("wiggleverse", "meta", branch, f"rfcs/{slug}.md")]["content"]
|
||||
sc = fake.files.get(
|
||||
("wiggleverse", "meta", branch, f"rfcs/{slug}.meta.yaml"), {}).get("content")
|
||||
e, _ = metadata.read_entry(md, sc, fallback_slug=slug)
|
||||
return e
|
||||
|
||||
|
||||
def seed_owned_super_draft(fake: FakeGitea, *, slug: str, title: str, pitch: str,
|
||||
owners: list[str], arbiters: list[str] | None = None,
|
||||
proposed_by: str = "alice", tags: list[str] | None = None) -> None:
|
||||
@@ -190,9 +202,8 @@ def test_graduate_happy_path_flips_in_place_keeping_body(app_with_fake_gitea):
|
||||
k[1].startswith("rfc-0042") for k in fake.repos
|
||||
), f"a per-RFC repo was created: {fake.repos}"
|
||||
|
||||
# Meta entry on main: state flipped, body KEPT, repo null.
|
||||
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
graduated = entry_mod.parse(meta_text)
|
||||
# Meta entry on main: state flipped (sidecar), body KEPT (.md), repo null.
|
||||
graduated = _entry_from_git(fake, "ohm")
|
||||
assert graduated.state == "active"
|
||||
assert graduated.id == "RFC-0042"
|
||||
assert graduated.repo is None
|
||||
@@ -224,6 +235,34 @@ def test_graduate_happy_path_flips_in_place_keeping_body(app_with_fake_gitea):
|
||||
assert gone not in kinds, f"retired audit row present: {gone}"
|
||||
|
||||
|
||||
def test_graduate_preserves_unknown_frontmatter_keys(app_with_fake_gitea):
|
||||
"""§22.4a INV-7: a forward-compat / unknown frontmatter key on the
|
||||
super-draft entry must ride through the graduation rebuild, not be dropped."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import entry as entry_mod
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH,
|
||||
owners=["ben"], arbiters=["ben"])
|
||||
# Inject an unknown key into the seeded entry's frontmatter.
|
||||
key = ("wiggleverse", "meta", "main", "rfcs/ohm.md")
|
||||
e = entry_mod.parse(fake.files[key]["content"])
|
||||
e.extra["priority"] = "P1"
|
||||
fake.files[key]["content"] = entry_mod.serialize(e)
|
||||
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner", email="ben@test")
|
||||
r = client.post("/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0042", "owners": ["ben"]})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
graduated = _entry_from_git(fake, "ohm")
|
||||
assert graduated.state == "active"
|
||||
assert graduated.extra.get("priority") == "P1"
|
||||
|
||||
|
||||
def test_graduate_coexists_with_open_body_edit_pr(app_with_fake_gitea):
|
||||
"""§9.8 (meta-only): an open meta-repo body-edit PR no longer blocks
|
||||
graduation — the body is kept, so they coexist. /check stays
|
||||
@@ -571,8 +610,7 @@ def test_graduate_without_number_flips_to_active_null_id_by_slug(app_with_fake_g
|
||||
assert d["rfc_id"] is None
|
||||
|
||||
# Meta entry: active, id null, body kept, graduation stamped.
|
||||
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
graduated = entry_mod.parse(meta_text)
|
||||
graduated = _entry_from_git(fake, "ohm")
|
||||
assert graduated.state == "active"
|
||||
assert graduated.id is None
|
||||
assert graduated.graduated_by == "ben"
|
||||
@@ -621,9 +659,7 @@ def test_graduate_with_number_unchanged_when_id_absent_field(app_with_fake_gitea
|
||||
r = client.post("/api/rfcs/ohm/graduate?_sync=1", json={"owners": ["ben"]})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["rfc_id"] is None
|
||||
graduated = entry_mod.parse(
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
)
|
||||
graduated = _entry_from_git(fake, "ohm")
|
||||
assert graduated.state == "active"
|
||||
assert graduated.id is None
|
||||
|
||||
@@ -649,8 +685,7 @@ def test_claim_opens_meta_pr(app_with_fake_gitea):
|
||||
d = r.json()
|
||||
assert d["branch_name"] == "claim/ohm"
|
||||
|
||||
text = fake.files[("wiggleverse", "meta", "claim/ohm", "rfcs/ohm.md")]["content"]
|
||||
ent = entry_mod.parse(text)
|
||||
ent = _entry_from_git(fake, "ohm", branch="claim/ohm")
|
||||
assert "alice" in ent.owners
|
||||
|
||||
row = db.conn().execute(
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
"""§22.8 S6 — request-to-join a scope + the cross-collection inbox.
|
||||
|
||||
A user who knows a (gated) scope exists asks to join it, naming a desired role;
|
||||
the request is recorded and fanned out to that scope's Owners *across the
|
||||
subtree* (the cross-collection inbox, §22.11). An Owner accepts — which writes
|
||||
the `memberships` row via memberships.grant — or declines, and the requester is
|
||||
§15-notified either way.
|
||||
|
||||
Built by analogy to test_contributions_vertical.py (the per-RFC contribute flow)
|
||||
and test_s4_invitations_vertical.py (the scope/membership world-builders).
|
||||
|
||||
World: project "ohm" owns collections "model" (document, gated) and "features"
|
||||
(bdd, gated). eve is project Owner; dan is collection Owner of features only;
|
||||
zoe is a global Owner; ada is a deployment admin. ben is a plain granted account
|
||||
(no scope role) — the would-be joiner.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import db
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# World-builders (mirror the S4 vertical)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _project(pid: str, visibility: str = "gated", content_repo: str = "meta") -> None:
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO projects (id, name, content_repo, visibility, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, datetime('now'))",
|
||||
(pid, pid.capitalize(), content_repo, visibility),
|
||||
)
|
||||
|
||||
|
||||
def _collection(cid: str, project_id: str, *, ctype: str = "document",
|
||||
visibility: str = "gated") -> None:
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO collections "
|
||||
"(id, project_id, type, subfolder, initial_state, visibility, name, created_at, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, 'super-draft', ?, ?, datetime('now'), datetime('now'))",
|
||||
(cid, project_id, ctype, cid, visibility, cid.capitalize()),
|
||||
)
|
||||
|
||||
|
||||
def _grant(scope_type: str, scope_id: str, user_id: int, role: str) -> None:
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(scope_type, scope_id, user_id, role),
|
||||
)
|
||||
|
||||
|
||||
def _membership(user_id: int):
|
||||
rows = db.conn().execute(
|
||||
"SELECT scope_type, scope_id, role FROM memberships WHERE user_id = ?",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
return {(r["scope_type"], r["scope_id"], r["role"]) for r in rows}
|
||||
|
||||
|
||||
def _join_requests(scope_type: str, scope_id: str):
|
||||
rows = db.conn().execute(
|
||||
"SELECT id, requester_user_id, requested_role, status, granted_role "
|
||||
"FROM join_requests WHERE scope_type = ? AND scope_id = ?",
|
||||
(scope_type, scope_id),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def _join_notif_recipients(event_kind: str = "join_request_on_scope") -> set[int]:
|
||||
return {
|
||||
r["recipient_user_id"]
|
||||
for r in db.conn().execute(
|
||||
"SELECT recipient_user_id FROM notifications WHERE event_kind = ?",
|
||||
(event_kind,),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _seed_world() -> None:
|
||||
_project("ohm", "gated")
|
||||
_collection("model", "ohm", ctype="document")
|
||||
_collection("features", "ohm", ctype="bdd")
|
||||
provision_user_row(user_id=2, login="ben", role="contributor") # the joiner
|
||||
provision_user_row(user_id=4, login="dan", role="contributor") # collection Owner (features)
|
||||
provision_user_row(user_id=5, login="eve", role="contributor") # project Owner
|
||||
provision_user_row(user_id=6, login="zoe", role="contributor") # global Owner
|
||||
provision_user_row(user_id=7, login="ada", role="admin") # deployment admin
|
||||
_grant("project", "ohm", 5, "owner")
|
||||
_grant("collection", "features", 4, "owner")
|
||||
_grant("global", "*", 6, "owner")
|
||||
|
||||
|
||||
def _login(client, uid: int, login: str, role: str = "contributor") -> None:
|
||||
sign_in_as(client, user_id=uid, gitea_login=login, display_name=login.capitalize(), role=role)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request → cross-collection fan-out
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_request_to_join_collection_fans_out_to_subtree_owners(app_with_fake_gitea):
|
||||
"""A request to join a collection lands a row and notifies every Owner whose
|
||||
reach covers it — the collection's Owner, the project's Owner, a global
|
||||
Owner, and the deployment admin (the cross-collection inbox) — never the
|
||||
requester."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login(client, 2, "ben")
|
||||
r = client.post(
|
||||
"/api/scopes/collection/features/join-requests",
|
||||
json={"role": "contributor", "message": "I work on BDD corpora."},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["status"] == "pending"
|
||||
|
||||
reqs = _join_requests("collection", "features")
|
||||
assert len(reqs) == 1
|
||||
assert reqs[0]["requester_user_id"] == 2
|
||||
assert reqs[0]["requested_role"] == "contributor"
|
||||
assert reqs[0]["status"] == "pending"
|
||||
|
||||
# Owners across the subtree are notified; ben (requester) is not.
|
||||
recips = _join_notif_recipients()
|
||||
assert {4, 5, 6, 7}.issubset(recips) # dan, eve, zoe, ada
|
||||
assert 2 not in recips
|
||||
|
||||
|
||||
def test_request_to_join_project_reaches_project_and_global_owners(app_with_fake_gitea):
|
||||
"""A project-scope request reaches the project's Owners + global Owners +
|
||||
admin, but NOT a collection-only Owner (their reach doesn't cover the
|
||||
project)."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login(client, 2, "ben")
|
||||
r = client.post(
|
||||
"/api/scopes/project/ohm/join-requests",
|
||||
json={"role": "owner"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
recips = _join_notif_recipients()
|
||||
assert {5, 6, 7}.issubset(recips) # eve (project), zoe (global), ada (admin)
|
||||
assert 4 not in recips # dan is only a collection Owner
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Accept → writes membership + notifies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_owner_accept_writes_membership_and_notifies(app_with_fake_gitea):
|
||||
"""The collection Owner accepts; a `memberships` row is written at the
|
||||
requested scope/role and the requester gets a join_request_accepted inbox
|
||||
row."""
|
||||
from app import auth
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login(client, 2, "ben")
|
||||
client.post(
|
||||
"/api/scopes/collection/features/join-requests",
|
||||
json={"role": "contributor"},
|
||||
)
|
||||
req_id = _join_requests("collection", "features")[0]["id"]
|
||||
|
||||
# dan (collection Owner of features) accepts.
|
||||
_login(client, 4, "dan")
|
||||
r = client.post(
|
||||
f"/api/scopes/collection/features/join-requests/{req_id}/accept",
|
||||
json={},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["granted_role"] == "contributor"
|
||||
|
||||
# ben now holds the collection role and can contribute there.
|
||||
assert ("collection", "features", "contributor") in _membership(2)
|
||||
ben = auth.SessionUser(
|
||||
user_id=2, gitea_id=2, gitea_login="ben", display_name="Ben",
|
||||
email="ben@test", avatar_url="", role="contributor", permission_state="granted",
|
||||
)
|
||||
assert auth.can_contribute_in_collection(ben, "features") is True
|
||||
assert auth.can_contribute_in_collection(ben, "model") is False
|
||||
|
||||
# the row is closed; the requester is notified.
|
||||
assert _join_requests("collection", "features")[0]["status"] == "accepted"
|
||||
_login(client, 2, "ben")
|
||||
inbox = client.get("/api/notifications").json()["items"]
|
||||
accepted = [n for n in inbox if n["event_kind"] == "join_request_accepted"]
|
||||
assert accepted, inbox
|
||||
assert "Features" in accepted[0]["summary"]
|
||||
|
||||
|
||||
def test_owner_may_narrow_role_on_accept(app_with_fake_gitea):
|
||||
"""A request for Owner may be accepted as RFC Contributor — the Owner narrows
|
||||
the grant; the membership row carries the granted (not requested) role."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login(client, 2, "ben")
|
||||
client.post("/api/scopes/collection/features/join-requests", json={"role": "owner"})
|
||||
req_id = _join_requests("collection", "features")[0]["id"]
|
||||
|
||||
_login(client, 5, "eve") # project Owner — reach covers the collection
|
||||
r = client.post(
|
||||
f"/api/scopes/collection/features/join-requests/{req_id}/accept",
|
||||
json={"role": "contributor"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert ("collection", "features", "contributor") in _membership(2)
|
||||
assert _join_requests("collection", "features")[0]["granted_role"] == "contributor"
|
||||
|
||||
|
||||
def test_owner_decline_notifies_and_grants_nothing(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login(client, 2, "ben")
|
||||
client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
|
||||
req_id = _join_requests("collection", "features")[0]["id"]
|
||||
|
||||
_login(client, 4, "dan")
|
||||
r = client.post(f"/api/scopes/collection/features/join-requests/{req_id}/decline")
|
||||
assert r.status_code == 200, r.text
|
||||
assert _membership(2) == set()
|
||||
assert _join_requests("collection", "features")[0]["status"] == "declined"
|
||||
|
||||
_login(client, 2, "ben")
|
||||
inbox = client.get("/api/notifications").json()["items"]
|
||||
assert any(n["event_kind"] == "join_request_declined" for n in inbox)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gates & guards
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_duplicate_pending_request_is_conflict(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login(client, 2, "ben")
|
||||
r1 = client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
|
||||
assert r1.status_code == 200, r1.text
|
||||
r2 = client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
|
||||
assert r2.status_code == 409, r2.text
|
||||
|
||||
|
||||
def test_existing_member_cannot_request(app_with_fake_gitea):
|
||||
"""dan already owns the collection — there is nothing to request (409)."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login(client, 4, "dan")
|
||||
r = client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
|
||||
assert r.status_code == 409, r.text
|
||||
|
||||
|
||||
def test_non_owner_cannot_accept(app_with_fake_gitea):
|
||||
"""A plain requester (or any non-Owner) is refused the accept action."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login(client, 2, "ben")
|
||||
client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
|
||||
req_id = _join_requests("collection", "features")[0]["id"]
|
||||
# provision a second plain account that tries to accept
|
||||
provision_user_row(user_id=12, login="mal", role="contributor")
|
||||
_login(client, 12, "mal")
|
||||
r = client.post(
|
||||
f"/api/scopes/collection/features/join-requests/{req_id}/accept", json={}
|
||||
)
|
||||
assert r.status_code == 403, r.text
|
||||
assert _membership(2) == set()
|
||||
|
||||
|
||||
def test_collection_owner_cannot_act_on_sibling_collection(app_with_fake_gitea):
|
||||
"""dan owns 'features' only; a request to join 'model' is not his to act on."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login(client, 2, "ben")
|
||||
client.post("/api/scopes/collection/model/join-requests", json={"role": "contributor"})
|
||||
req_id = _join_requests("collection", "model")[0]["id"]
|
||||
_login(client, 4, "dan")
|
||||
r = client.post(
|
||||
f"/api/scopes/collection/model/join-requests/{req_id}/accept", json={}
|
||||
)
|
||||
assert r.status_code == 403, r.text
|
||||
|
||||
|
||||
def test_unknown_scope_404(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login(client, 2, "ben")
|
||||
assert client.post(
|
||||
"/api/scopes/collection/nope/join-requests", json={"role": "contributor"}
|
||||
).status_code == 404
|
||||
assert client.post(
|
||||
"/api/scopes/project/nope/join-requests", json={"role": "contributor"}
|
||||
).status_code == 404
|
||||
# 'global' is not a join-able scope_type.
|
||||
assert client.post(
|
||||
"/api/scopes/global/*/join-requests", json={"role": "contributor"}
|
||||
).status_code == 404
|
||||
|
||||
|
||||
def test_join_target_reports_eligibility(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
# ben: eligible (granted, no role).
|
||||
_login(client, 2, "ben")
|
||||
t = client.get("/api/scopes/collection/features/join-target").json()
|
||||
assert t["eligible"] is True
|
||||
assert t["name"] == "Features"
|
||||
assert t["current_role"] is None
|
||||
# after requesting, already_requested flips and eligible drops.
|
||||
client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
|
||||
t2 = client.get("/api/scopes/collection/features/join-target").json()
|
||||
assert t2["already_requested"] is True
|
||||
assert t2["eligible"] is False
|
||||
# dan: already a member → ineligible with current_role.
|
||||
_login(client, 4, "dan")
|
||||
t3 = client.get("/api/scopes/collection/features/join-target").json()
|
||||
assert t3["eligible"] is False
|
||||
assert t3["current_role"] == "owner"
|
||||
@@ -47,12 +47,15 @@ def test_mark_reviewed_clears_flag(app_with_fake_gitea):
|
||||
assert row["unreviewed"] == 0
|
||||
assert row["reviewed_by"] == "ben"
|
||||
assert row["reviewed_at"] # provenance stamped
|
||||
# git-side: the entry file on main was rewritten with the cleared flag.
|
||||
from app import entry as entry_mod
|
||||
# git-side (§22.4a SLICE-4): the cleared flag now lands in the metadata
|
||||
# sidecar and the `.md` is lazy-migrated to a clean body-only file (INV-2).
|
||||
import yaml
|
||||
sidecar = fake.files[("wiggleverse", "meta", "main", "rfcs/feat.meta.yaml")]["content"]
|
||||
sc = yaml.safe_load(sidecar)
|
||||
assert not sc.get("unreviewed") # cleared (omitted when False)
|
||||
assert sc.get("reviewed_by") == "ben"
|
||||
written = fake.files[("wiggleverse", "meta", "main", "rfcs/feat.md")]["content"]
|
||||
e = entry_mod.parse(written)
|
||||
assert e.unreviewed is False
|
||||
assert e.reviewed_by == "ben"
|
||||
assert "---" not in written # body-only, no frontmatter
|
||||
|
||||
|
||||
def test_mark_reviewed_forbidden_for_non_superuser(app_with_fake_gitea):
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"""SLICE-1 unit tests — sidecar metadata: dual-read, unknown-key preservation,
|
||||
frontmatter stripping, malformed detection.
|
||||
|
||||
Pure functions only (no DB / no Gitea). Per
|
||||
docs/design/2026-06-06-configurable-collection-metadata.md §7.2 (SLICE-1) and
|
||||
INV-6 (dual-read), INV-7 (unknown keys ride along), INV-2 (clean body).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import yaml
|
||||
|
||||
from app import entry as entry_mod
|
||||
from app import metadata
|
||||
|
||||
|
||||
LEGACY_MD = """---
|
||||
slug: view-metrics
|
||||
title: View today's metrics
|
||||
state: active
|
||||
owners:
|
||||
- ben.stull
|
||||
tags:
|
||||
- dashboard
|
||||
- analytics
|
||||
priority: P1
|
||||
owner: hasan
|
||||
---
|
||||
|
||||
This is the prose body.
|
||||
|
||||
Second paragraph.
|
||||
"""
|
||||
|
||||
|
||||
# ---- INV-7: unknown keys ride along ----
|
||||
|
||||
def test_parse_preserves_unknown_keys_in_extra():
|
||||
e = entry_mod.parse(LEGACY_MD)
|
||||
assert e.extra == {"priority": "P1", "owner": "hasan"}
|
||||
|
||||
|
||||
def test_serialize_round_trip_preserves_unknown_keys():
|
||||
e = entry_mod.parse(LEGACY_MD)
|
||||
text = entry_mod.serialize(e)
|
||||
e2 = entry_mod.parse(text)
|
||||
assert e2.extra == {"priority": "P1", "owner": "hasan"}
|
||||
assert e2.tags == ["dashboard", "analytics"]
|
||||
assert e2.owners == ["ben.stull"]
|
||||
|
||||
|
||||
def test_known_keys_never_leak_into_extra():
|
||||
e = entry_mod.parse(LEGACY_MD)
|
||||
for known in ("slug", "title", "state", "owners", "tags"):
|
||||
assert known not in e.extra
|
||||
|
||||
|
||||
# ---- metadata_dict / sidecar_yaml ----
|
||||
|
||||
def test_metadata_dict_merges_known_and_extra():
|
||||
e = entry_mod.parse(LEGACY_MD)
|
||||
d = metadata.metadata_dict(e)
|
||||
assert d["slug"] == "view-metrics"
|
||||
assert d["title"] == "View today's metrics"
|
||||
assert d["state"] == "active"
|
||||
assert d["tags"] == ["dashboard", "analytics"]
|
||||
# forward-compat keys present
|
||||
assert d["priority"] == "P1"
|
||||
assert d["owner"] == "hasan"
|
||||
|
||||
|
||||
def test_sidecar_yaml_is_parseable_and_has_no_frontmatter_fences():
|
||||
e = entry_mod.parse(LEGACY_MD)
|
||||
sc = metadata.sidecar_yaml(e)
|
||||
assert "---" not in sc.splitlines()[0]
|
||||
loaded = yaml.safe_load(sc)
|
||||
assert loaded["slug"] == "view-metrics"
|
||||
assert loaded["priority"] == "P1"
|
||||
|
||||
|
||||
# ---- strip_frontmatter (INV-2) ----
|
||||
|
||||
def test_strip_frontmatter_removes_leading_block():
|
||||
body = metadata.strip_frontmatter(LEGACY_MD)
|
||||
assert body.startswith("This is the prose body.")
|
||||
assert "slug:" not in body
|
||||
assert "priority:" not in body
|
||||
|
||||
|
||||
def test_strip_frontmatter_passthrough_when_no_frontmatter():
|
||||
plain = "Just a body.\n\nNo frontmatter here.\n"
|
||||
assert metadata.strip_frontmatter(plain).strip() == plain.strip()
|
||||
|
||||
|
||||
# ---- parse_sidecar (malformed detection, INV-3) ----
|
||||
|
||||
def test_parse_sidecar_good():
|
||||
values, malformed = metadata.parse_sidecar("slug: a\ntitle: A\npriority: P0\n")
|
||||
assert malformed is False
|
||||
assert values == {"slug": "a", "title": "A", "priority": "P0"}
|
||||
|
||||
|
||||
def test_parse_sidecar_non_mapping_is_malformed():
|
||||
values, malformed = metadata.parse_sidecar("- just\n- a\n- list\n")
|
||||
assert malformed is True
|
||||
assert values == {}
|
||||
|
||||
|
||||
def test_parse_sidecar_invalid_yaml_is_malformed():
|
||||
values, malformed = metadata.parse_sidecar("slug: : : not yaml\n bad: [unclosed\n")
|
||||
assert malformed is True
|
||||
assert values == {}
|
||||
|
||||
|
||||
def test_parse_sidecar_empty_is_empty_not_malformed():
|
||||
values, malformed = metadata.parse_sidecar("")
|
||||
assert malformed is False
|
||||
assert values == {}
|
||||
|
||||
|
||||
# ---- read_entry dual-read equivalence (INV-6) ----
|
||||
|
||||
def test_dual_read_sidecar_matches_legacy():
|
||||
legacy_entry, legacy_bad = metadata.read_entry(LEGACY_MD, None)
|
||||
|
||||
# The migrated form: body-only .md + a sidecar holding the metadata.
|
||||
migrated_md = metadata.strip_frontmatter(LEGACY_MD)
|
||||
sidecar_text = metadata.sidecar_yaml(legacy_entry)
|
||||
sidecar_entry, sidecar_bad = metadata.read_entry(migrated_md, sidecar_text)
|
||||
|
||||
assert legacy_bad is False
|
||||
assert sidecar_bad is False
|
||||
# Identical resulting records (INV-6).
|
||||
assert sidecar_entry.slug == legacy_entry.slug
|
||||
assert sidecar_entry.title == legacy_entry.title
|
||||
assert sidecar_entry.state == legacy_entry.state
|
||||
assert sidecar_entry.owners == legacy_entry.owners
|
||||
assert sidecar_entry.tags == legacy_entry.tags
|
||||
assert sidecar_entry.extra == legacy_entry.extra
|
||||
assert sidecar_entry.body.strip() == legacy_entry.body.strip()
|
||||
|
||||
|
||||
def test_read_entry_sidecar_takes_precedence_over_md_frontmatter():
|
||||
# A not-yet-migrated .md still carrying frontmatter, plus a sidecar that
|
||||
# disagrees: the sidecar wins for metadata; the body comes from the .md.
|
||||
md_with_fm = "---\nslug: old\ntitle: Old Title\nstate: super-draft\n---\n\nBody.\n"
|
||||
sidecar = "slug: new\ntitle: New Title\nstate: active\n"
|
||||
e, malformed = metadata.read_entry(md_with_fm, sidecar)
|
||||
assert malformed is False
|
||||
assert e.title == "New Title"
|
||||
assert e.state == "active"
|
||||
assert e.body.strip() == "Body."
|
||||
|
||||
|
||||
def test_read_entry_malformed_sidecar_still_loads_entry():
|
||||
# INV-3: a malformed sidecar never hard-fails the read. The entry loads
|
||||
# (from the .md frontmatter if present) and is flagged malformed.
|
||||
md = "---\nslug: x\ntitle: X\nstate: active\n---\n\nBody.\n"
|
||||
e, malformed = metadata.read_entry(md, "- not a mapping\n")
|
||||
assert malformed is True
|
||||
assert e.slug == "x"
|
||||
assert e.title == "X"
|
||||
assert e.body.strip() == "Body."
|
||||
|
||||
|
||||
# ---- dual-read robustness: degenerate sidecars never drop the entry ----
|
||||
|
||||
def test_empty_sidecar_falls_back_to_md_frontmatter():
|
||||
# An empty sidecar has no metadata to override with — keep the .md's.
|
||||
md = "---\nslug: keep\ntitle: Keep Me\nstate: active\n---\n\nBody.\n"
|
||||
e, malformed = metadata.read_entry(md, "", fallback_slug="keep")
|
||||
assert malformed is False
|
||||
assert e.slug == "keep"
|
||||
assert e.title == "Keep Me"
|
||||
|
||||
|
||||
def test_malformed_sidecar_on_body_only_md_loads_with_fallback_slug():
|
||||
# The .md is already body-only (migrated) and the sidecar is corrupt:
|
||||
# the entry must still load (INV-3), taking its slug from the filename stem.
|
||||
e, malformed = metadata.read_entry("Just a body.\n", "- a\n- list\n", fallback_slug="foo")
|
||||
assert malformed is True
|
||||
assert e.slug == "foo"
|
||||
|
||||
|
||||
def test_slugless_sidecar_uses_fallback_slug():
|
||||
md = "Body only.\n"
|
||||
sidecar = "title: No Slug Here\nstate: active\n"
|
||||
e, malformed = metadata.read_entry(md, sidecar, fallback_slug="bar")
|
||||
assert malformed is False
|
||||
assert e.slug == "bar"
|
||||
assert e.title == "No Slug Here"
|
||||
|
||||
|
||||
# ---- sidecar filename helpers ----
|
||||
|
||||
def test_sidecar_filename_helpers():
|
||||
assert metadata.sidecar_name("view-metrics") == "view-metrics.meta.yaml"
|
||||
assert metadata.is_sidecar("view-metrics.meta.yaml") is True
|
||||
assert metadata.is_sidecar("view-metrics.md") is False
|
||||
assert metadata.slug_of_sidecar("view-metrics.meta.yaml") == "view-metrics"
|
||||
|
||||
|
||||
# ---- SLICE-4: sidecar_path_for + apply_values ----
|
||||
|
||||
def test_sidecar_path_for_derives_sibling():
|
||||
assert metadata.sidecar_path_for("rfcs/alpha.md") == "rfcs/alpha.meta.yaml"
|
||||
assert metadata.sidecar_path_for("x/y/beta.md") == "x/y/beta.meta.yaml"
|
||||
|
||||
|
||||
def test_apply_values_updates_known_and_extra_fields():
|
||||
e = entry_mod.parse(LEGACY_MD) # has tags + extra priority/owner
|
||||
e2 = metadata.apply_values(e, {"tags": ["x"], "priority": "P0", "owner": "sam"})
|
||||
assert e2.tags == ["x"]
|
||||
assert e2.extra["priority"] == "P0"
|
||||
assert e2.extra["owner"] == "sam"
|
||||
# body preserved unchanged
|
||||
assert e2.body == e.body
|
||||
|
||||
|
||||
def test_apply_values_preserves_unspecified_keys():
|
||||
e = entry_mod.parse(LEGACY_MD)
|
||||
e2 = metadata.apply_values(e, {"priority": "P0"})
|
||||
assert e2.tags == e.tags # untouched
|
||||
assert e2.extra["owner"] == "hasan" # untouched
|
||||
@@ -0,0 +1,232 @@
|
||||
"""SLICE-5 — bulk metadata edit endpoint (PUC-2, §6.4/§6.5).
|
||||
|
||||
Through the real API: contributor+ gating (INV-4), set/add/remove ops,
|
||||
validation at the write boundary, one commit for N sidecars (D7), and
|
||||
partial-rejection reporting. Reuses the fake-Gitea harness.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import yaml
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import cache, db, gitea as gitea_mod
|
||||
from app.config import load_config
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401 (fixtures)
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
PID = "default"
|
||||
CID = "default"
|
||||
BASE = f"/api/projects/{PID}/collections/{CID}"
|
||||
|
||||
|
||||
def _refresh():
|
||||
cfg = load_config()
|
||||
asyncio.run(cache.refresh_meta_repo(cfg, gitea_mod.Gitea(cfg)))
|
||||
|
||||
|
||||
def _set_fields(schema):
|
||||
db.conn().execute(
|
||||
"UPDATE collections SET config_json = ? WHERE id = 'default'",
|
||||
(json.dumps({"fields": schema}),))
|
||||
|
||||
|
||||
def _seed_legacy(fake, slug, *, state="active", **front):
|
||||
fm = {"slug": slug, "title": slug.title(), "state": state, **front}
|
||||
body = yaml.safe_dump(fm, sort_keys=False).strip()
|
||||
fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.md")] = {
|
||||
"content": f"---\n{body}\n---\n\nBody.\n", "sha": slug}
|
||||
|
||||
|
||||
def _login_owner(client):
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
|
||||
|
||||
|
||||
def _has_sidecar(fake, slug):
|
||||
return ("wiggleverse", "meta", "main", f"rfcs/{slug}.meta.yaml") in fake.files
|
||||
|
||||
|
||||
def _sidecar(fake, slug):
|
||||
return yaml.safe_load(
|
||||
fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.meta.yaml")]["content"])
|
||||
|
||||
|
||||
# ---- Task 1: happy path, one commit ----
|
||||
|
||||
def test_bulk_set_applies_to_all_and_one_commit(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_set_fields({"priority": {"type": "enum", "values": ["P0", "P1", "P2"]}})
|
||||
_seed_legacy(fake, "a", priority="P2")
|
||||
_seed_legacy(fake, "b", priority="P1")
|
||||
_refresh()
|
||||
_login_owner(client)
|
||||
commits_before = fake.change_files_calls
|
||||
r = client.post(f"{BASE}/meta/bulk",
|
||||
json={"slugs": ["a", "b"], "op": "set",
|
||||
"field": "priority", "value": "P0"})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert set(body["applied"]) == {"a", "b"}
|
||||
assert body["rejected"] == []
|
||||
assert body["committed"] is True
|
||||
# exactly one ChangeFiles commit covered both entries (D7)
|
||||
assert fake.change_files_calls - commits_before == 1
|
||||
assert _sidecar(fake, "a")["priority"] == "P0"
|
||||
assert _sidecar(fake, "b")["priority"] == "P0"
|
||||
|
||||
|
||||
# ---- Task 2: add/remove tags ----
|
||||
|
||||
def test_bulk_add_tag(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_set_fields({"tags": {"type": "tags"}})
|
||||
_seed_legacy(fake, "a", tags=["x"])
|
||||
_seed_legacy(fake, "b", tags=["x", "y"])
|
||||
_refresh()
|
||||
_login_owner(client)
|
||||
r = client.post(f"{BASE}/meta/bulk",
|
||||
json={"slugs": ["a", "b"], "op": "add",
|
||||
"field": "tags", "value": "y"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert set(r.json()["applied"]) == {"a", "b"}
|
||||
# "a" gained y; "b" already had y (no-op write skipped → no sidecar written)
|
||||
assert _sidecar(fake, "a")["tags"] == ["x", "y"]
|
||||
assert not _has_sidecar(fake, "b")
|
||||
|
||||
|
||||
def test_bulk_remove_tag(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_set_fields({"tags": {"type": "tags"}})
|
||||
_seed_legacy(fake, "a", tags=["x", "y"])
|
||||
_refresh()
|
||||
_login_owner(client)
|
||||
r = client.post(f"{BASE}/meta/bulk",
|
||||
json={"slugs": ["a"], "op": "remove",
|
||||
"field": "tags", "value": "x"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["applied"] == ["a"]
|
||||
assert _sidecar(fake, "a")["tags"] == ["y"]
|
||||
|
||||
|
||||
def test_bulk_set_scalar_on_tags_rejected(app_with_fake_gitea):
|
||||
# A scalar `set` onto a tags field must reject, not char-split into a list.
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_set_fields({"tags": {"type": "tags"}})
|
||||
_seed_legacy(fake, "a", tags=["x"])
|
||||
_refresh()
|
||||
_login_owner(client)
|
||||
r = client.post(f"{BASE}/meta/bulk",
|
||||
json={"slugs": ["a"], "op": "set",
|
||||
"field": "tags", "value": "checkout"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["applied"] == []
|
||||
assert len(r.json()["rejected"]) == 1
|
||||
assert r.json()["committed"] is False
|
||||
assert not _has_sidecar(fake, "a")
|
||||
|
||||
|
||||
def test_bulk_set_list_on_tags_ok(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_set_fields({"tags": {"type": "tags"}})
|
||||
_seed_legacy(fake, "a", tags=["x"])
|
||||
_refresh()
|
||||
_login_owner(client)
|
||||
r = client.post(f"{BASE}/meta/bulk",
|
||||
json={"slugs": ["a"], "op": "set",
|
||||
"field": "tags", "value": ["x", "y"]})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["applied"] == ["a"]
|
||||
assert _sidecar(fake, "a")["tags"] == ["x", "y"]
|
||||
|
||||
|
||||
def test_bulk_add_remove_requires_tags_field(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_set_fields({"priority": {"type": "enum", "values": ["P0"]}})
|
||||
_seed_legacy(fake, "a", priority="P0")
|
||||
_refresh()
|
||||
_login_owner(client)
|
||||
r = client.post(f"{BASE}/meta/bulk",
|
||||
json={"slugs": ["a"], "op": "add",
|
||||
"field": "priority", "value": "z"})
|
||||
assert r.status_code == 422, r.text
|
||||
|
||||
|
||||
# ---- Task 3: partial rejection, authz, validation guards ----
|
||||
|
||||
def test_bulk_partial_reject_missing_entry(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_set_fields({"priority": {"type": "enum", "values": ["P0", "P1"]}})
|
||||
_seed_legacy(fake, "a", priority="P1")
|
||||
_refresh()
|
||||
_login_owner(client)
|
||||
r = client.post(f"{BASE}/meta/bulk",
|
||||
json={"slugs": ["a", "ghost"], "op": "set",
|
||||
"field": "priority", "value": "P0"})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["applied"] == ["a"]
|
||||
assert body["rejected"] == [{"slug": "ghost", "reason": "not found"}]
|
||||
assert body["committed"] is True
|
||||
assert _sidecar(fake, "a")["priority"] == "P0"
|
||||
|
||||
|
||||
def test_bulk_invalid_value_rejects_all_no_commit(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_set_fields({"priority": {"type": "enum", "values": ["P0", "P1"]}})
|
||||
_seed_legacy(fake, "a", priority="P1")
|
||||
_refresh()
|
||||
_login_owner(client)
|
||||
r = client.post(f"{BASE}/meta/bulk",
|
||||
json={"slugs": ["a"], "op": "set",
|
||||
"field": "priority", "value": "ZZZ"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["applied"] == []
|
||||
assert len(r.json()["rejected"]) == 1
|
||||
assert r.json()["committed"] is False
|
||||
assert not _has_sidecar(fake, "a")
|
||||
|
||||
|
||||
def test_bulk_forbidden_for_anonymous(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_set_fields({"priority": {"type": "enum", "values": ["P0"]}})
|
||||
_seed_legacy(fake, "a", priority="P0")
|
||||
_refresh()
|
||||
r = client.post(f"{BASE}/meta/bulk",
|
||||
json={"slugs": ["a"], "op": "set",
|
||||
"field": "priority", "value": "P0"})
|
||||
assert r.status_code == 403, r.text
|
||||
|
||||
|
||||
def test_bulk_unknown_field_op_and_empty(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_set_fields({"priority": {"type": "enum", "values": ["P0"]}})
|
||||
_seed_legacy(fake, "a", priority="P0")
|
||||
_refresh()
|
||||
_login_owner(client)
|
||||
assert client.post(f"{BASE}/meta/bulk",
|
||||
json={"slugs": ["a"], "op": "set",
|
||||
"field": "nope", "value": "P0"}).status_code == 422
|
||||
assert client.post(f"{BASE}/meta/bulk",
|
||||
json={"slugs": ["a"], "op": "frobnicate",
|
||||
"field": "priority", "value": "P0"}).status_code == 422
|
||||
assert client.post(f"{BASE}/meta/bulk",
|
||||
json={"slugs": [], "op": "set",
|
||||
"field": "priority", "value": "P0"}).status_code == 422
|
||||
@@ -0,0 +1,177 @@
|
||||
"""SLICE-1 integration — the corpus mirror reads sidecars (dual-read) and
|
||||
derives the malformed flag (PUC-6, INV-3/INV-6).
|
||||
|
||||
Per docs/design/2026-06-06-configurable-collection-metadata.md §6.2-6.3.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import cache, db, gitea as gitea_mod
|
||||
from app.config import load_config
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401 (fixtures)
|
||||
app_with_fake_gitea,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
def _refresh():
|
||||
cfg = load_config()
|
||||
asyncio.run(cache.refresh_meta_repo(cfg, gitea_mod.Gitea(cfg)))
|
||||
|
||||
|
||||
def _row(slug):
|
||||
return db.conn().execute(
|
||||
"SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)
|
||||
).fetchone()
|
||||
|
||||
|
||||
def test_mirror_reads_metadata_from_sidecar(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
# A migrated entry: body-only .md + a sidecar holding the metadata.
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/sidecar-one.md")] = {
|
||||
"content": "Just the prose body.\n", "sha": "s1"}
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/sidecar-one.meta.yaml")] = {
|
||||
"content": "slug: sidecar-one\ntitle: From Sidecar\nstate: active\ntags:\n- alpha\n",
|
||||
"sha": "m1"}
|
||||
_refresh()
|
||||
|
||||
row = _row("sidecar-one")
|
||||
assert row is not None
|
||||
assert row["title"] == "From Sidecar"
|
||||
assert row["state"] == "active"
|
||||
assert row["body"].strip() == "Just the prose body."
|
||||
assert row["metadata_malformed"] == 0
|
||||
|
||||
|
||||
def test_malformed_sidecar_flags_but_still_loads(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
# .md still has frontmatter; sidecar is malformed (a list, not a map).
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/bad-meta.md")] = {
|
||||
"content": "---\nslug: bad-meta\ntitle: Legacy Title\nstate: active\n---\n\nBody.\n",
|
||||
"sha": "b1"}
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/bad-meta.meta.yaml")] = {
|
||||
"content": "- not\n- a\n- mapping\n", "sha": "b2"}
|
||||
_refresh()
|
||||
|
||||
row = _row("bad-meta")
|
||||
assert row is not None # INV-3: still loads
|
||||
assert row["metadata_malformed"] == 1
|
||||
# Falls back to the legacy .md frontmatter for the metadata.
|
||||
assert row["title"] == "Legacy Title"
|
||||
|
||||
|
||||
def test_malformed_flag_surfaces_in_catalog_api(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/flagged.md")] = {
|
||||
"content": "---\nslug: flagged\ntitle: Flagged\nstate: active\n---\n\nB.\n",
|
||||
"sha": "f1"}
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/flagged.meta.yaml")] = {
|
||||
"content": "just a scalar\n", "sha": "f2"}
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/clean.md")] = {
|
||||
"content": "---\nslug: clean\ntitle: Clean\nstate: active\n---\n\nB.\n",
|
||||
"sha": "c1"}
|
||||
_refresh()
|
||||
|
||||
items = {i["slug"]: i for i in client.get("/api/rfcs").json()["items"]}
|
||||
assert items["flagged"]["metadata_malformed"] is True
|
||||
assert items["clean"]["metadata_malformed"] is False
|
||||
|
||||
# And on the detail view.
|
||||
assert client.get("/api/rfcs/flagged").json()["metadata_malformed"] is True
|
||||
|
||||
|
||||
def test_malformed_sidecar_on_migrated_entry_still_loads_flagged(app_with_fake_gitea):
|
||||
# INV-3 regression: a migrated (body-only .md) entry whose sidecar is
|
||||
# corrupt must NOT vanish from the catalog — it loads (slug from the
|
||||
# filename stem) and is flagged malformed.
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/orphaned.md")] = {
|
||||
"content": "Just the body, no frontmatter.\n", "sha": "o1"}
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/orphaned.meta.yaml")] = {
|
||||
"content": "- corrupt\n- list\n", "sha": "o2"}
|
||||
_refresh()
|
||||
|
||||
row = _row("orphaned")
|
||||
assert row is not None # did not vanish
|
||||
assert row["metadata_malformed"] == 1
|
||||
assert row["body"].strip() == "Just the body, no frontmatter."
|
||||
|
||||
|
||||
def _set_default_fields_schema(schema):
|
||||
# apply_registry leaves the default collection's config_json untouched, so a
|
||||
# schema set here survives a corpus refresh (refresh_meta_repo only).
|
||||
db.conn().execute(
|
||||
"UPDATE collections SET config_json = ? WHERE id = 'default'",
|
||||
(json.dumps({"fields": schema}),))
|
||||
|
||||
|
||||
# ---- §22.4a SLICE-2: advisory schema validation at ingest (INV-3) ----
|
||||
|
||||
def test_schema_violation_flags_malformed(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
_set_default_fields_schema(
|
||||
{"priority": {"type": "enum", "values": ["P0", "P1", "P2"]}})
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/bad-prio.md")] = {
|
||||
"content": "---\nslug: bad-prio\ntitle: Bad\nstate: active\npriority: P9\n---\n\nB.\n",
|
||||
"sha": "bp1"}
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/good-prio.md")] = {
|
||||
"content": "---\nslug: good-prio\ntitle: Good\nstate: active\npriority: P0\n---\n\nB.\n",
|
||||
"sha": "gp1"}
|
||||
_refresh()
|
||||
|
||||
assert _row("bad-prio")["metadata_malformed"] == 1 # INV-3: flagged
|
||||
assert _row("bad-prio")["title"] == "Bad" # still loads
|
||||
assert _row("good-prio")["metadata_malformed"] == 0
|
||||
|
||||
|
||||
def test_schema_ignores_undeclared_keys(app_with_fake_gitea):
|
||||
# INV-7: keys the schema doesn't declare ride along and never flag malformed.
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
_set_default_fields_schema(
|
||||
{"priority": {"type": "enum", "values": ["P0", "P1"]}})
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/extra-key.md")] = {
|
||||
"content": "---\nslug: extra-key\ntitle: Extra\nstate: active\nowner: hasan\n---\n\nB.\n",
|
||||
"sha": "ek1"}
|
||||
_refresh()
|
||||
|
||||
assert _row("extra-key")["metadata_malformed"] == 0
|
||||
|
||||
|
||||
def test_no_schema_never_flags(app_with_fake_gitea):
|
||||
# INV-5: a collection with no field schema validates nothing, even when an
|
||||
# entry carries values that would fail a schema if one existed.
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/anything.md")] = {
|
||||
"content": "---\nslug: anything\ntitle: Any\nstate: active\npriority: whatever\n---\n\nB.\n",
|
||||
"sha": "an1"}
|
||||
_refresh()
|
||||
|
||||
assert _row("anything")["metadata_malformed"] == 0
|
||||
|
||||
|
||||
def test_legacy_collection_without_sidecars_unchanged(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/legacy.md")] = {
|
||||
"content": "---\nslug: legacy\ntitle: Legacy\nstate: super-draft\n---\n\nPitch.\n",
|
||||
"sha": "l1"}
|
||||
_refresh()
|
||||
|
||||
row = _row("legacy")
|
||||
assert row is not None
|
||||
assert row["title"] == "Legacy"
|
||||
assert row["state"] == "super-draft"
|
||||
assert row["body"].strip() == "Pitch."
|
||||
assert row["metadata_malformed"] == 0
|
||||
@@ -0,0 +1,190 @@
|
||||
"""SLICE-4 — single-entry metadata edit endpoint (PUC-1, §6.4).
|
||||
|
||||
Through the real API: contributor+ gating (INV-4), schema validation at the
|
||||
write boundary, direct commit to the sidecar with lazy migration, re-ingest,
|
||||
and the GET RFC `meta` + `can_edit_meta` exposure. Reuses the fake-Gitea harness.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import yaml
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import cache, db, gitea as gitea_mod
|
||||
from app.config import load_config
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401 (fixtures)
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
PID = "default"
|
||||
CID = "default"
|
||||
META = f"/api/projects/{PID}/collections/{CID}"
|
||||
|
||||
|
||||
def _refresh():
|
||||
cfg = load_config()
|
||||
asyncio.run(cache.refresh_meta_repo(cfg, gitea_mod.Gitea(cfg)))
|
||||
|
||||
|
||||
def _set_fields(schema):
|
||||
db.conn().execute(
|
||||
"UPDATE collections SET config_json = ? WHERE id = 'default'",
|
||||
(json.dumps({"fields": schema}),))
|
||||
|
||||
|
||||
def _seed_legacy(fake, slug, *, state="active", **front):
|
||||
fm = {"slug": slug, "title": slug.title(), "state": state, **front}
|
||||
body = yaml.safe_dump(fm, sort_keys=False).strip()
|
||||
fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.md")] = {
|
||||
"content": f"---\n{body}\n---\n\nBody.\n", "sha": slug}
|
||||
|
||||
|
||||
def _seed_migrated(fake, slug, sidecar):
|
||||
fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.md")] = {
|
||||
"content": "Body.\n", "sha": f"{slug}-md"}
|
||||
fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.meta.yaml")] = {
|
||||
"content": sidecar, "sha": f"{slug}-sc"}
|
||||
|
||||
|
||||
def _login_owner(client):
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
|
||||
|
||||
|
||||
def test_edit_meta_sets_value_commits_sidecar_and_lazy_migrates(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_set_fields({"priority": {"type": "enum", "values": ["P0", "P1", "P2"]},
|
||||
"tags": {"type": "tags"}})
|
||||
_seed_legacy(fake, "a", priority="P1", tags=["x"])
|
||||
_refresh()
|
||||
_login_owner(client)
|
||||
r = client.post(f"{META}/rfcs/a/meta", json={"values": {"priority": "P0"}})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["meta"]["priority"] == "P0"
|
||||
# sidecar written, .md lazy-migrated to body-only
|
||||
sc = yaml.safe_load(
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/a.meta.yaml")]["content"])
|
||||
assert sc["priority"] == "P0"
|
||||
assert "---" not in fake.files[("wiggleverse", "meta", "main", "rfcs/a.md")]["content"]
|
||||
# cache reflects the new value
|
||||
row = db.conn().execute(
|
||||
"SELECT meta_json FROM cached_rfcs WHERE slug='a'").fetchone()
|
||||
assert json.loads(row["meta_json"])["priority"] == "P0"
|
||||
|
||||
|
||||
def test_edit_meta_rejects_value_outside_enum(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_set_fields({"priority": {"type": "enum", "values": ["P0", "P1"]}})
|
||||
_seed_legacy(fake, "a", priority="P1")
|
||||
_refresh()
|
||||
_login_owner(client)
|
||||
r = client.post(f"{META}/rfcs/a/meta", json={"values": {"priority": "ZZZ"}})
|
||||
assert r.status_code == 422, r.text
|
||||
# nothing committed
|
||||
assert ("wiggleverse", "meta", "main", "rfcs/a.meta.yaml") not in fake.files
|
||||
|
||||
|
||||
def test_edit_meta_rejects_unknown_field(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_set_fields({"priority": {"type": "enum", "values": ["P0"]}})
|
||||
_seed_legacy(fake, "a", priority="P0")
|
||||
_refresh()
|
||||
_login_owner(client)
|
||||
r = client.post(f"{META}/rfcs/a/meta", json={"values": {"nope": "x"}})
|
||||
assert r.status_code == 422, r.text
|
||||
|
||||
|
||||
def test_edit_meta_forbidden_for_anonymous(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_set_fields({"priority": {"type": "enum", "values": ["P0"]}})
|
||||
_seed_legacy(fake, "a", priority="P0")
|
||||
_refresh()
|
||||
r = client.post(f"{META}/rfcs/a/meta", json={"values": {"priority": "P0"}})
|
||||
assert r.status_code == 403, r.text
|
||||
|
||||
|
||||
def test_edit_meta_on_already_migrated_entry(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_set_fields({"priority": {"type": "enum", "values": ["P0", "P1"]}})
|
||||
_seed_migrated(fake, "a", "slug: a\ntitle: A\nstate: active\npriority: P1\n")
|
||||
_refresh()
|
||||
_login_owner(client)
|
||||
r = client.post(f"{META}/rfcs/a/meta", json={"values": {"priority": "P0"}})
|
||||
assert r.status_code == 200, r.text
|
||||
sc = yaml.safe_load(
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/a.meta.yaml")]["content"])
|
||||
assert sc["priority"] == "P0"
|
||||
# .md untouched (still body-only)
|
||||
assert fake.files[("wiggleverse", "meta", "main", "rfcs/a.md")]["content"] == "Body.\n"
|
||||
|
||||
|
||||
def test_get_rfc_exposes_meta_and_can_edit(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_set_fields({"priority": {"type": "enum", "values": ["P0", "P1"]}})
|
||||
_seed_legacy(fake, "a", priority="P1")
|
||||
_refresh()
|
||||
# anonymous: meta present, can_edit_meta False
|
||||
r = client.get(f"{META}/rfcs/a")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["meta"]["priority"] == "P1"
|
||||
assert r.json()["can_edit_meta"] is False
|
||||
# owner: can_edit_meta True
|
||||
_login_owner(client)
|
||||
r = client.get(f"{META}/rfcs/a")
|
||||
assert r.json()["can_edit_meta"] is True
|
||||
|
||||
|
||||
# ---- Owner-gated collection migrate endpoint (PUC-5) ----
|
||||
|
||||
def test_migrate_collection_endpoint_owner(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_legacy(fake, "a", priority="P1", tags=["x"])
|
||||
_seed_legacy(fake, "b", priority="P0")
|
||||
_refresh()
|
||||
_login_owner(client)
|
||||
r = client.post(f"{META}/migrate")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["committed"] is True
|
||||
assert set(r.json()["migrated"]) == {"a", "b"}
|
||||
# both entries now body-only + sidecar
|
||||
for slug in ("a", "b"):
|
||||
assert "---" not in fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.md")]["content"]
|
||||
assert ("wiggleverse", "meta", "main", f"rfcs/{slug}.meta.yaml") in fake.files
|
||||
|
||||
|
||||
def test_migrate_collection_forbidden_for_contributor(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_legacy(fake, "a", priority="P1")
|
||||
_refresh()
|
||||
provision_user_row(user_id=2, login="carol", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="carol",
|
||||
display_name="Carol", role="contributor")
|
||||
r = client.post(f"{META}/migrate")
|
||||
assert r.status_code == 403, r.text
|
||||
|
||||
|
||||
def test_migrate_collection_idempotent(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_legacy(fake, "a", priority="P1")
|
||||
_refresh()
|
||||
_login_owner(client)
|
||||
assert client.post(f"{META}/migrate").json()["committed"] is True
|
||||
# second run: nothing left to migrate
|
||||
r2 = client.post(f"{META}/migrate")
|
||||
assert r2.status_code == 200, r2.text
|
||||
assert r2.json()["committed"] is False
|
||||
@@ -0,0 +1,108 @@
|
||||
"""SLICE-4 — git-aware sidecar read/write helpers.
|
||||
|
||||
Uses the FakeGitea from the propose-vertical fixtures (no network).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import yaml
|
||||
|
||||
from app import gitea as gitea_mod, metadata
|
||||
from app.config import load_config
|
||||
|
||||
from test_propose_vertical import app_with_fake_gitea, tmp_env # noqa: F401
|
||||
|
||||
LEGACY = """---
|
||||
slug: alpha
|
||||
title: Alpha
|
||||
state: active
|
||||
owners:
|
||||
- ben.stull
|
||||
tags:
|
||||
- one
|
||||
priority: P1
|
||||
---
|
||||
|
||||
Alpha body.
|
||||
"""
|
||||
|
||||
MIGRATED_MD = "Alpha body.\n"
|
||||
MIGRATED_SIDECAR = """slug: alpha
|
||||
title: Alpha
|
||||
state: active
|
||||
owners:
|
||||
- ben.stull
|
||||
tags:
|
||||
- one
|
||||
priority: P1
|
||||
"""
|
||||
|
||||
|
||||
def _gitea():
|
||||
return gitea_mod.Gitea(load_config())
|
||||
|
||||
|
||||
def test_read_entry_from_git_legacy(app_with_fake_gitea):
|
||||
_app, fake = app_with_fake_gitea
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")] = {
|
||||
"content": LEGACY, "sha": "s1"}
|
||||
st = asyncio.run(metadata.read_entry_from_git(
|
||||
_gitea(), "wiggleverse", "meta", "rfcs/alpha.md"))
|
||||
assert st is not None
|
||||
assert st.entry.slug == "alpha"
|
||||
assert st.entry.extra["priority"] == "P1"
|
||||
assert st.sidecar_sha is None # no sidecar yet
|
||||
assert st.malformed is False
|
||||
|
||||
|
||||
def test_read_entry_from_git_migrated(app_with_fake_gitea):
|
||||
_app, fake = app_with_fake_gitea
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")] = {
|
||||
"content": MIGRATED_MD, "sha": "s1"}
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml")] = {
|
||||
"content": MIGRATED_SIDECAR, "sha": "s2"}
|
||||
st = asyncio.run(metadata.read_entry_from_git(
|
||||
_gitea(), "wiggleverse", "meta", "rfcs/alpha.md"))
|
||||
assert st.entry.extra["priority"] == "P1" # from sidecar
|
||||
assert st.entry.body == "Alpha body.\n"
|
||||
assert st.sidecar_sha == "s2"
|
||||
|
||||
|
||||
def test_read_entry_from_git_missing(app_with_fake_gitea):
|
||||
st = asyncio.run(metadata.read_entry_from_git(
|
||||
_gitea(), "wiggleverse", "meta", "rfcs/nope.md"))
|
||||
assert st is None
|
||||
|
||||
|
||||
def test_write_entry_files_lazy_migrates_legacy(app_with_fake_gitea):
|
||||
_app, fake = app_with_fake_gitea
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")] = {
|
||||
"content": LEGACY, "sha": "s1"}
|
||||
st = asyncio.run(metadata.read_entry_from_git(
|
||||
_gitea(), "wiggleverse", "meta", "rfcs/alpha.md"))
|
||||
e2 = metadata.apply_values(st.entry, {"priority": "P0"})
|
||||
ops = metadata.write_entry_files("rfcs/alpha.md", e2, st)
|
||||
paths = {o["path"]: o for o in ops}
|
||||
# sidecar created, .md rewritten body-only
|
||||
assert "rfcs/alpha.meta.yaml" in paths
|
||||
assert paths["rfcs/alpha.meta.yaml"]["operation"] == "create"
|
||||
assert paths["rfcs/alpha.md"]["operation"] == "update"
|
||||
assert "---" not in paths["rfcs/alpha.md"]["content"] # INV-2 clean body
|
||||
assert yaml.safe_load(paths["rfcs/alpha.meta.yaml"]["content"])["priority"] == "P0"
|
||||
|
||||
|
||||
def test_write_entry_files_already_migrated_touches_sidecar_only(app_with_fake_gitea):
|
||||
_app, fake = app_with_fake_gitea
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")] = {
|
||||
"content": MIGRATED_MD, "sha": "s1"}
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml")] = {
|
||||
"content": MIGRATED_SIDECAR, "sha": "s2"}
|
||||
st = asyncio.run(metadata.read_entry_from_git(
|
||||
_gitea(), "wiggleverse", "meta", "rfcs/alpha.md"))
|
||||
e2 = metadata.apply_values(st.entry, {"priority": "P0"})
|
||||
ops = metadata.write_entry_files("rfcs/alpha.md", e2, st)
|
||||
paths = {o["path"]: o for o in ops}
|
||||
assert set(paths) == {"rfcs/alpha.meta.yaml"} # .md untouched
|
||||
assert paths["rfcs/alpha.meta.yaml"]["operation"] == "update"
|
||||
assert paths["rfcs/alpha.meta.yaml"]["sha"] == "s2"
|
||||
@@ -0,0 +1,135 @@
|
||||
"""SLICE-1 integration — frontmatter→sidecar migration tool (PUC-5).
|
||||
|
||||
Per docs/design/2026-06-06-configurable-collection-metadata.md §6.5 / §7.2:
|
||||
a tool walks a collection; for each entry with legacy frontmatter it writes
|
||||
`<slug>.meta.yaml` and rewrites `<slug>.md` to the body only — one commit per
|
||||
collection, idempotent, preserving unknown keys (INV-7).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import yaml
|
||||
|
||||
from app import gitea as gitea_mod, metadata
|
||||
from app.config import load_config
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401 (fixtures)
|
||||
app_with_fake_gitea,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
ALPHA_MD = """---
|
||||
slug: alpha
|
||||
title: Alpha
|
||||
state: active
|
||||
owners:
|
||||
- ben.stull
|
||||
tags:
|
||||
- one
|
||||
priority: P1
|
||||
---
|
||||
|
||||
Alpha body prose.
|
||||
"""
|
||||
|
||||
BETA_MD = """---
|
||||
slug: beta
|
||||
title: Beta
|
||||
state: super-draft
|
||||
---
|
||||
|
||||
Beta body prose.
|
||||
"""
|
||||
|
||||
|
||||
def _seed_entries(fake):
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")] = {
|
||||
"content": ALPHA_MD, "sha": "a0001"}
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/beta.md")] = {
|
||||
"content": BETA_MD, "sha": "b0001"}
|
||||
|
||||
|
||||
def _run_migration(subfolder=""):
|
||||
cfg = load_config()
|
||||
gitea = gitea_mod.Gitea(cfg)
|
||||
return asyncio.run(
|
||||
metadata.migrate_collection(
|
||||
gitea, org="wiggleverse", repo="meta", subfolder=subfolder
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_migration_writes_sidecars_and_strips_bodies(app_with_fake_gitea):
|
||||
_app, fake = app_with_fake_gitea
|
||||
_seed_entries(fake)
|
||||
|
||||
summary = _run_migration()
|
||||
|
||||
assert sorted(summary["migrated"]) == ["alpha", "beta"]
|
||||
|
||||
# Sidecars now exist.
|
||||
alpha_sc = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml")]["content"]
|
||||
beta_sc = fake.files[("wiggleverse", "meta", "main", "rfcs/beta.meta.yaml")]["content"]
|
||||
alpha_vals = yaml.safe_load(alpha_sc)
|
||||
assert alpha_vals["slug"] == "alpha"
|
||||
assert alpha_vals["title"] == "Alpha"
|
||||
assert alpha_vals["tags"] == ["one"]
|
||||
# INV-7: the unknown key rides along into the sidecar.
|
||||
assert alpha_vals["priority"] == "P1"
|
||||
|
||||
# .md bodies are stripped of frontmatter (INV-2).
|
||||
alpha_md = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")]["content"]
|
||||
assert "---" not in alpha_md
|
||||
assert "priority:" not in alpha_md
|
||||
assert alpha_md.strip() == "Alpha body prose."
|
||||
assert beta_sc # beta got a sidecar too
|
||||
|
||||
|
||||
def test_migration_is_idempotent(app_with_fake_gitea):
|
||||
_app, fake = app_with_fake_gitea
|
||||
_seed_entries(fake)
|
||||
|
||||
first = _run_migration()
|
||||
assert sorted(first["migrated"]) == ["alpha", "beta"]
|
||||
assert first["committed"] is True
|
||||
|
||||
commits_after_first = fake._commit_counter
|
||||
alpha_md_after_first = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")]["content"]
|
||||
|
||||
second = _run_migration()
|
||||
assert second["migrated"] == []
|
||||
assert second["committed"] is False
|
||||
# No new commit; files untouched.
|
||||
assert fake._commit_counter == commits_after_first
|
||||
assert fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")]["content"] == alpha_md_after_first
|
||||
|
||||
|
||||
def test_migration_one_commit_for_whole_collection(app_with_fake_gitea):
|
||||
_app, fake = app_with_fake_gitea
|
||||
_seed_entries(fake)
|
||||
before = fake._commit_counter
|
||||
_run_migration()
|
||||
# Two entries migrated in exactly one commit (ChangeFiles batch).
|
||||
assert fake._commit_counter == before + 1
|
||||
|
||||
|
||||
def test_migration_dual_read_equivalence_after_migrate(app_with_fake_gitea):
|
||||
"""An entry reads identically before and after migration (INV-6)."""
|
||||
_app, fake = app_with_fake_gitea
|
||||
_seed_entries(fake)
|
||||
|
||||
before, _ = metadata.read_entry(ALPHA_MD, None)
|
||||
_run_migration()
|
||||
md = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")]["content"]
|
||||
sc = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml")]["content"]
|
||||
after, malformed = metadata.read_entry(md, sc)
|
||||
|
||||
assert malformed is False
|
||||
assert after.slug == before.slug
|
||||
assert after.title == before.title
|
||||
assert after.state == before.state
|
||||
assert after.tags == before.tags
|
||||
assert after.extra == before.extra
|
||||
assert after.body.strip() == before.body.strip()
|
||||
@@ -0,0 +1,149 @@
|
||||
"""SLICE-2 unit tests — collection field schema + central validation.
|
||||
|
||||
Pure functions only (no DB / no Gitea). Per
|
||||
docs/design/2026-06-06-configurable-collection-metadata.md §7.2 (SLICE-2):
|
||||
`metadata_schema.parse_fields` (lenient schema parsing) and
|
||||
`metadata_schema.validate` (advisory at read / enforcement point at write).
|
||||
Honors INV-3 (never hard-fails), INV-5 (no-fields unchanged), INV-7 (undeclared
|
||||
keys ride along).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app import metadata_schema as ms
|
||||
|
||||
|
||||
# ---- parse_fields: normalization + leniency ----
|
||||
|
||||
def test_parse_fields_each_type():
|
||||
raw = {
|
||||
"priority": {"type": "enum", "values": ["P0", "P1", "P2"], "label": "Priority"},
|
||||
"tags": {"type": "tags"},
|
||||
"owner": {"type": "text"},
|
||||
}
|
||||
fields = ms.parse_fields(raw)
|
||||
assert list(fields) == ["priority", "tags", "owner"] # order preserved
|
||||
assert fields["priority"] == {
|
||||
"type": "enum",
|
||||
"values": ["P0", "P1", "P2"],
|
||||
"label": "Priority",
|
||||
}
|
||||
assert fields["tags"] == {"type": "tags"}
|
||||
assert fields["owner"] == {"type": "text"}
|
||||
|
||||
|
||||
def test_parse_fields_controlled_tags_keeps_values():
|
||||
fields = ms.parse_fields({"area": {"type": "tags", "values": ["a", "b"]}})
|
||||
assert fields["area"] == {"type": "tags", "values": ["a", "b"]}
|
||||
|
||||
|
||||
def test_parse_fields_missing_block_is_empty():
|
||||
assert ms.parse_fields(None) == {}
|
||||
assert ms.parse_fields({}) == {}
|
||||
|
||||
|
||||
def test_parse_fields_non_mapping_block_skipped():
|
||||
assert ms.parse_fields(["not", "a", "mapping"]) == {}
|
||||
assert ms.parse_fields("nope") == {}
|
||||
|
||||
|
||||
def test_parse_fields_enum_without_values_skipped():
|
||||
# enum requires a non-empty values list — skipped, not fatal.
|
||||
assert ms.parse_fields({"p": {"type": "enum"}}) == {}
|
||||
assert ms.parse_fields({"p": {"type": "enum", "values": []}}) == {}
|
||||
|
||||
|
||||
def test_parse_fields_unknown_type_skipped():
|
||||
fields = ms.parse_fields(
|
||||
{"good": {"type": "text"}, "bad": {"type": "ref"}, "huh": {"type": "frob"}}
|
||||
)
|
||||
assert list(fields) == ["good"]
|
||||
|
||||
|
||||
def test_parse_fields_non_mapping_def_skipped():
|
||||
fields = ms.parse_fields({"good": {"type": "text"}, "bad": "scalar"})
|
||||
assert list(fields) == ["good"]
|
||||
|
||||
|
||||
def test_parse_fields_values_coerced_to_str_list():
|
||||
fields = ms.parse_fields({"p": {"type": "enum", "values": [0, 1, 2]}})
|
||||
assert fields["p"]["values"] == ["0", "1", "2"]
|
||||
|
||||
|
||||
# ---- validate: advisory problem reporting ----
|
||||
|
||||
SCHEMA = ms.parse_fields(
|
||||
{
|
||||
"priority": {"type": "enum", "values": ["P0", "P1", "P2"]},
|
||||
"tags": {"type": "tags"},
|
||||
"area": {"type": "tags", "values": ["checkout", "cart"]},
|
||||
"owner": {"type": "text"},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_validate_happy():
|
||||
values = {
|
||||
"priority": "P0",
|
||||
"tags": ["anything", "free"],
|
||||
"area": ["checkout"],
|
||||
"owner": "ben",
|
||||
}
|
||||
assert ms.validate(values, SCHEMA) == []
|
||||
|
||||
|
||||
def test_validate_absent_fields_ok():
|
||||
# A declared field that the entry omits is fine (no required fields in v1).
|
||||
assert ms.validate({}, SCHEMA) == []
|
||||
|
||||
|
||||
def test_validate_enum_bad_value():
|
||||
problems = ms.validate({"priority": "P9"}, SCHEMA)
|
||||
assert [p.field for p in problems] == ["priority"]
|
||||
assert problems[0].code == "not-in-values"
|
||||
|
||||
|
||||
def test_validate_enum_wrong_type():
|
||||
problems = ms.validate({"priority": ["P0"]}, SCHEMA)
|
||||
assert problems[0].field == "priority"
|
||||
assert problems[0].code == "wrong-type"
|
||||
|
||||
|
||||
def test_validate_tags_free_form_ok():
|
||||
assert ms.validate({"tags": ["x", "y", "z"]}, SCHEMA) == []
|
||||
|
||||
|
||||
def test_validate_tags_wrong_type():
|
||||
problems = ms.validate({"tags": "notalist"}, SCHEMA)
|
||||
assert problems[0].field == "tags"
|
||||
assert problems[0].code == "wrong-type"
|
||||
|
||||
|
||||
def test_validate_controlled_tags_bad_member():
|
||||
problems = ms.validate({"area": ["checkout", "nope"]}, SCHEMA)
|
||||
assert problems[0].field == "area"
|
||||
assert problems[0].code == "not-in-values"
|
||||
|
||||
|
||||
def test_validate_text_wrong_type():
|
||||
problems = ms.validate({"owner": ["a", "b"]}, SCHEMA)
|
||||
assert problems[0].field == "owner"
|
||||
assert problems[0].code == "wrong-type"
|
||||
|
||||
|
||||
def test_validate_undeclared_keys_ignored():
|
||||
# INV-7: keys outside the schema ride along untouched, never flagged.
|
||||
assert ms.validate({"random": "value", "slug": "x", "title": "y"}, SCHEMA) == []
|
||||
|
||||
|
||||
def test_validate_empty_schema_no_problems():
|
||||
# INV-5: a collection with no fields validates everything as clean.
|
||||
assert ms.validate({"priority": "anything", "x": 1}, {}) == []
|
||||
|
||||
|
||||
def test_problem_as_dict():
|
||||
p = ms.Problem(field="priority", code="not-in-values", message="bad")
|
||||
assert p.as_dict() == {
|
||||
"field": "priority",
|
||||
"code": "not-in-values",
|
||||
"message": "bad",
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"""SLICE-4 — write paths are sidecar-aware: a migrated (body-only `.md` +
|
||||
sidecar) entry never crashes `entry.parse` nor re-grows frontmatter."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from app import gitea as gitea_mod, metadata
|
||||
from app.bot import Actor, Bot
|
||||
from app.config import load_config
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
BODY_ONLY = "Alpha prose body.\n"
|
||||
SIDECAR = ("slug: alpha\ntitle: Alpha\nstate: active\n"
|
||||
"owners:\n- ben.stull\ntags:\n- one\npriority: P1\n")
|
||||
|
||||
|
||||
def _seed_migrated(fake, repo="meta"):
|
||||
fake.files[("wiggleverse", repo, "main", "rfcs/alpha.md")] = {
|
||||
"content": BODY_ONLY, "sha": "m1"}
|
||||
fake.files[("wiggleverse", repo, "main", "rfcs/alpha.meta.yaml")] = {
|
||||
"content": SIDECAR, "sha": "m2"}
|
||||
|
||||
|
||||
def _actor():
|
||||
return Actor(user_id=1, gitea_login="ben.stull", display_name="Ben", email="ben@x.io")
|
||||
|
||||
|
||||
def test_mark_entry_reviewed_on_migrated_entry(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
_seed_migrated(fake)
|
||||
gitea = gitea_mod.Gitea(load_config())
|
||||
bot = Bot(gitea)
|
||||
with TestClient(app):
|
||||
provision_user_row(user_id=1, login="ben.stull", role="owner")
|
||||
# Must not raise (legacy code parsed body-only .md → ValueError).
|
||||
asyncio.run(bot.mark_entry_reviewed(
|
||||
_actor(), org="wiggleverse", meta_repo="meta", slug="alpha",
|
||||
reviewed_by="ben.stull", reviewed_at="2026-06-07"))
|
||||
md = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")]["content"]
|
||||
sc = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml")]["content"]
|
||||
assert "---" not in md # body stays clean (no re-grown FM)
|
||||
assert "reviewed_by: ben.stull" in sc # review stamp landed in the sidecar
|
||||
|
||||
|
||||
# ---- Task 3.2: body extract/wrap helpers (api_branches) ----
|
||||
|
||||
def test_extract_wrap_body_on_body_only_md():
|
||||
from app import api_branches
|
||||
rfc = {"state": "super-draft", "repo": None, "slug": "alpha", "collection_id": "default"}
|
||||
body = api_branches._extract_body_pure(rfc, BODY_ONLY, "main", is_meta=True)
|
||||
assert body == BODY_ONLY
|
||||
wrapped = api_branches._wrap_body_pure(rfc, BODY_ONLY, "new body\n", "main", is_meta=True)
|
||||
assert wrapped == "new body\n" # stays clean — no re-grown frontmatter
|
||||
|
||||
|
||||
def test_wrap_body_preserves_legacy_frontmatter():
|
||||
from app import api_branches
|
||||
legacy = "---\nslug: alpha\ntitle: Alpha\nstate: active\n---\n\nold body\n"
|
||||
rfc = {"state": "super-draft", "repo": None, "slug": "alpha", "collection_id": "default"}
|
||||
wrapped = api_branches._wrap_body_pure(rfc, legacy, "new body\n", "main", is_meta=True)
|
||||
assert wrapped.startswith("---") # legacy frontmatter preserved
|
||||
assert "new body" in wrapped
|
||||
|
||||
|
||||
# ---- Task 3.3: PR-replay body wrappers (api_prs) ----
|
||||
|
||||
def test_replay_wrappers_on_body_only():
|
||||
from app import api_prs
|
||||
assert api_prs._extract_body_for_replay(True, BODY_ONLY) == BODY_ONLY
|
||||
out = api_prs._wrap_body_for_replay(True, BODY_ONLY, "new\n")
|
||||
assert out == "new\n" # clean, no re-grown frontmatter
|
||||
legacy = "---\nslug: a\ntitle: A\nstate: active\n---\n\nold\n"
|
||||
out2 = api_prs._wrap_body_for_replay(True, legacy, "new\n")
|
||||
assert out2.startswith("---") # legacy preserved
|
||||
|
||||
|
||||
# ---- Task 3.4: retire a fully-migrated (body-only + sidecar) entry ----
|
||||
|
||||
def test_retire_already_migrated_entry_does_not_crash(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import cache, db
|
||||
from app.config import load_config
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
_seed_migrated(fake)
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
from test_propose_vertical import sign_in_as
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
|
||||
# Ingest the migrated entry so the catalog/cache knows it.
|
||||
asyncio.run(cache.refresh_meta_repo(load_config(), gitea_mod.Gitea(load_config())))
|
||||
assert db.conn().execute(
|
||||
"SELECT state FROM cached_rfcs WHERE slug='alpha'").fetchone()["state"] == "active"
|
||||
r = client.post("/api/rfcs/alpha/retire")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["state"] == "retired"
|
||||
import yaml as _yaml
|
||||
sc = _yaml.safe_load(
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml")]["content"])
|
||||
assert sc["state"] == "retired"
|
||||
md = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")]["content"]
|
||||
assert "---" not in md # body stayed clean
|
||||
|
||||
|
||||
# ---- Task 3.5: graduate a fully-migrated super-draft entry ----
|
||||
|
||||
SUPER_SIDECAR = ("slug: alpha\ntitle: Alpha\nstate: super-draft\n"
|
||||
"owners:\n- ben\ntags:\n- one\npriority: P1\n")
|
||||
|
||||
|
||||
def test_graduate_already_migrated_super_draft(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import cache, db
|
||||
from app.config import load_config
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")] = {
|
||||
"content": BODY_ONLY, "sha": "m1"}
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml")] = {
|
||||
"content": SUPER_SIDECAR, "sha": "m2"}
|
||||
with TestClient(app) as client:
|
||||
from test_propose_vertical import sign_in_as
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
|
||||
asyncio.run(cache.refresh_meta_repo(load_config(), gitea_mod.Gitea(load_config())))
|
||||
assert db.conn().execute(
|
||||
"SELECT state FROM cached_rfcs WHERE slug='alpha'").fetchone()["state"] == "super-draft"
|
||||
r = client.post("/api/rfcs/alpha/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0007", "owners": ["ben"]})
|
||||
assert r.status_code == 200, r.text
|
||||
import yaml as _yaml
|
||||
sc = _yaml.safe_load(
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml")]["content"])
|
||||
assert sc["state"] == "active"
|
||||
assert sc["id"] == "RFC-0007"
|
||||
assert sc.get("priority") == "P1" # INV-7 carried through graduation
|
||||
md = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")]["content"]
|
||||
assert "---" not in md # body stayed clean
|
||||
@@ -137,3 +137,63 @@ def test_memberships_table_replaces_project_members():
|
||||
conn.execute("INSERT INTO memberships (scope_type, scope_id, user_id, role) VALUES ('bogus','default',9,'owner')")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute("INSERT INTO memberships (scope_type, scope_id, user_id, role) VALUES ('project','default',9,'viewer')")
|
||||
|
||||
|
||||
# ── regression: §22.13 satellite re-stamp repair (the OHM-data deploy fault) ──
|
||||
# Reproduces the shape that crashed the v0.46.0 deploy: the default→ohm re-stamp
|
||||
# updated cached_rfcs but left cached_branches at the stale project_id='default',
|
||||
# with (a) a stale row duplicating a freshly-stamped one, (b) a stale row with no
|
||||
# fresh counterpart, and (c) a stale row whose RFC no longer exists. 029 must
|
||||
# repair all three rather than hit NOT NULL / UNIQUE on the rebuild.
|
||||
|
||||
def _apply_through(path, ceiling):
|
||||
conn = sqlite3.connect(path, isolation_level=None)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS schema_migrations (version TEXT PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (datetime('now')))")
|
||||
done = {r["version"] for r in conn.execute("SELECT version FROM schema_migrations")}
|
||||
for p in sorted(db.MIGRATIONS_DIR.glob("*.sql")):
|
||||
v = p.stem
|
||||
if v in done or v > ceiling:
|
||||
continue
|
||||
sql = p.read_text()
|
||||
if "-- migrate:no-foreign-keys" in sql:
|
||||
conn.execute("PRAGMA foreign_keys = OFF")
|
||||
conn.executescript("BEGIN; " + sql + "; COMMIT;")
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
else:
|
||||
conn.executescript("BEGIN; " + sql + "; COMMIT;")
|
||||
conn.execute("INSERT INTO schema_migrations (version) VALUES (?)", (v,))
|
||||
return conn
|
||||
|
||||
|
||||
def test_029_repairs_stale_duplicate_and_orphan_satellite_rows():
|
||||
d = tempfile.mkdtemp()
|
||||
path = str(Path(d) / "t.db")
|
||||
conn = _apply_through(path, "028_project_scoped_keys")
|
||||
# simulate the §22.13 re-stamp having renamed the default project + its RFCs
|
||||
# to 'ohm', but NOT the satellite tables (the actual prod fault).
|
||||
conn.execute("UPDATE projects SET id='ohm' WHERE id='default'")
|
||||
conn.execute("INSERT INTO cached_rfcs (slug, title, state, project_id) VALUES ('human','Human','active','ohm')")
|
||||
conn.execute("INSERT INTO cached_branches (rfc_slug, branch_name, project_id) VALUES ('human','main','ohm')") # fresh/correct
|
||||
conn.execute("INSERT INTO cached_branches (rfc_slug, branch_name, project_id) VALUES ('human','main','default')") # stale DUP of the fresh one
|
||||
conn.execute("INSERT INTO cached_branches (rfc_slug, branch_name, project_id) VALUES ('human','edit-1','default')")# stale, unique -> re-stamp+keep
|
||||
conn.execute("INSERT INTO cached_branches (rfc_slug, branch_name, project_id) VALUES ('ghost','main','default')") # no live RFC -> drop
|
||||
conn.close()
|
||||
|
||||
# apply 029+ (the patched migration). Must NOT raise.
|
||||
db.run_migrations(_Cfg(path))
|
||||
conn = db.connect(path)
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT rfc_slug, branch_name, collection_id FROM cached_branches"
|
||||
).fetchall()
|
||||
got = {(r["rfc_slug"], r["branch_name"]) for r in rows}
|
||||
# every surviving row mapped to a collection (the single-project 'default' one)
|
||||
assert all(r["collection_id"] is not None for r in rows)
|
||||
assert {r["collection_id"] for r in rows} == {"default"}
|
||||
# the duplicate collapsed to exactly one human/main
|
||||
assert len([r for r in rows if (r["rfc_slug"], r["branch_name"]) == ("human", "main")]) == 1
|
||||
# the unique stale row survived (re-stamped)
|
||||
assert ("human", "edit-1") in got
|
||||
# the no-RFC stale row was dropped
|
||||
assert ("ghost", "main") not in got
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Migration 032 — the join_requests table (§22.8 S6).
|
||||
|
||||
Proves: the table exists with its CHECK constraints (scope_type ∈
|
||||
{project,collection}; role/status enums), the one-open-per-(scope,user) partial
|
||||
unique index holds, and a decided request frees a fresh ask.
|
||||
Template: test_migration_030_global_scope.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app import db
|
||||
|
||||
|
||||
class _Cfg:
|
||||
def __init__(self, path):
|
||||
self.database_path = path
|
||||
|
||||
|
||||
def _fresh_db():
|
||||
d = tempfile.mkdtemp()
|
||||
path = Path(d) / "t.db"
|
||||
db.run_migrations(_Cfg(str(path)))
|
||||
return db.connect(str(path))
|
||||
|
||||
|
||||
def _add_user(conn, uid, login):
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, gitea_id, gitea_login, display_name, role) "
|
||||
"VALUES (?, ?, ?, ?, 'contributor')",
|
||||
(uid, uid, login, login.capitalize()),
|
||||
)
|
||||
|
||||
|
||||
def _request(conn, scope_type="collection", scope_id="features", uid=1, role="contributor"):
|
||||
conn.execute(
|
||||
"INSERT INTO join_requests (scope_type, scope_id, requester_user_id, requested_role) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(scope_type, scope_id, uid, role),
|
||||
)
|
||||
|
||||
|
||||
def test_join_request_row_round_trips():
|
||||
conn = _fresh_db()
|
||||
_add_user(conn, 1, "ben")
|
||||
_request(conn)
|
||||
row = conn.execute("SELECT * FROM join_requests WHERE requester_user_id = 1").fetchone()
|
||||
assert row["scope_type"] == "collection"
|
||||
assert row["requested_role"] == "contributor"
|
||||
assert row["status"] == "pending"
|
||||
assert row["granted_role"] is None
|
||||
|
||||
|
||||
def test_global_scope_type_is_rejected():
|
||||
conn = _fresh_db()
|
||||
_add_user(conn, 1, "ben")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
_request(conn, scope_type="global", scope_id="*")
|
||||
|
||||
|
||||
def test_bad_role_and_status_rejected():
|
||||
conn = _fresh_db()
|
||||
_add_user(conn, 1, "ben")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
_request(conn, role="viewer")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute(
|
||||
"INSERT INTO join_requests (scope_type, scope_id, requester_user_id, requested_role, status) "
|
||||
"VALUES ('project', 'ohm', 1, 'owner', 'maybe')"
|
||||
)
|
||||
|
||||
|
||||
def test_one_open_request_per_scope_user():
|
||||
conn = _fresh_db()
|
||||
_add_user(conn, 1, "ben")
|
||||
_request(conn)
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
_request(conn)
|
||||
|
||||
|
||||
def test_decided_request_frees_a_fresh_ask():
|
||||
conn = _fresh_db()
|
||||
_add_user(conn, 1, "ben")
|
||||
_request(conn)
|
||||
conn.execute("UPDATE join_requests SET status = 'declined' WHERE requester_user_id = 1")
|
||||
# a second open ask is now allowed
|
||||
_request(conn)
|
||||
n = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM join_requests WHERE requester_user_id = 1"
|
||||
).fetchone()["n"]
|
||||
assert n == 2
|
||||
@@ -54,6 +54,9 @@ class FakeGitea:
|
||||
self.repos: set[tuple[str, str]] = set()
|
||||
self._pr_counter = 0
|
||||
self._commit_counter = 0
|
||||
# count of batch ChangeFiles commits (one per /contents POST with a
|
||||
# files[] array) — lets tests assert "N files, one commit" (§22.4a D7).
|
||||
self.change_files_calls = 0
|
||||
self._seed_repo("wiggleverse", "meta")
|
||||
# §22 M3: the deployment's project registry. Startup refresh_registry
|
||||
# reads projects.yaml here; the single 'default' project's content_repo
|
||||
@@ -280,6 +283,29 @@ class FakeGitea:
|
||||
return httpx.Response(200, json=children)
|
||||
return httpx.Response(404, json={"message": "not found"})
|
||||
|
||||
# POST /repos/{owner}/{repo}/contents — ChangeFiles (batch, one commit).
|
||||
# §22.4a SLICE-1: the frontmatter→sidecar migration writes N files in a
|
||||
# single commit. Matches the no-path /contents route (the per-path POST
|
||||
# below needs a /contents/<path> suffix).
|
||||
m_batch = re.fullmatch(r"/repos/([^/]+)/([^/]+)/contents/?", path)
|
||||
if method == "POST" and m_batch:
|
||||
owner, repo = m_batch.groups()
|
||||
branch = payload["branch"]
|
||||
self.change_files_calls += 1
|
||||
sha = self._next_sha()
|
||||
for f in payload["files"]:
|
||||
op = f["operation"]
|
||||
fpath = f["path"]
|
||||
if op == "delete":
|
||||
self.files.pop((owner, repo, branch, fpath), None)
|
||||
else:
|
||||
content = base64.b64decode(f["content"]).decode()
|
||||
self.files[(owner, repo, branch, fpath)] = {"content": content, "sha": sha}
|
||||
br = self.branches[(owner, repo)].setdefault(branch, {})
|
||||
br["sha"] = sha
|
||||
br["ts"] = "2026-05-23T00:00:00Z"
|
||||
return httpx.Response(201, json={"commit": {"sha": sha}})
|
||||
|
||||
# POST /repos/{owner}/{repo}/contents/{path}
|
||||
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/contents/(.+)", path)
|
||||
if method == "POST" and m:
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""The per-IP limiter budgets are env-overridable (test/PPE stacks drive the
|
||||
auth endpoints repeatedly from one IP); production leaves them unset and keeps
|
||||
the secure defaults. A non-positive / unparseable value falls back."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _reload_with(monkeypatch, **env):
|
||||
for k, v in env.items():
|
||||
if v is None:
|
||||
monkeypatch.delenv(k, raising=False)
|
||||
else:
|
||||
monkeypatch.setenv(k, v)
|
||||
import app.ratelimit as ratelimit
|
||||
return importlib.reload(ratelimit)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _restore():
|
||||
yield
|
||||
# Leave the module in its default state for other tests.
|
||||
import app.ratelimit as ratelimit
|
||||
importlib.reload(ratelimit)
|
||||
|
||||
|
||||
def test_defaults_when_unset(monkeypatch):
|
||||
rl = _reload_with(monkeypatch, RATELIMIT_OTC_REQUEST_MAX=None, RATELIMIT_VERIFY_MAX=None)
|
||||
assert rl.otc_request_limiter.max_events == 5
|
||||
assert rl.verify_limiter.max_events == 10
|
||||
|
||||
|
||||
def test_env_override(monkeypatch):
|
||||
rl = _reload_with(monkeypatch, RATELIMIT_OTC_REQUEST_MAX="1000", RATELIMIT_VERIFY_MAX="250")
|
||||
assert rl.otc_request_limiter.max_events == 1000
|
||||
assert rl.verify_limiter.max_events == 250
|
||||
|
||||
|
||||
def test_bad_value_falls_back_to_default(monkeypatch):
|
||||
rl = _reload_with(monkeypatch, RATELIMIT_OTC_REQUEST_MAX="nope", RATELIMIT_VERIFY_MAX="0")
|
||||
assert rl.otc_request_limiter.max_events == 5 # unparseable → default
|
||||
assert rl.verify_limiter.max_events == 10 # non-positive → default
|
||||
@@ -0,0 +1,156 @@
|
||||
"""§22 framework bug — migration-029 vs registry-mirror collection-id
|
||||
divergence for a multi-project deployment's default project.
|
||||
|
||||
Migration 029 seeds the default project's collection id as the literal
|
||||
'default' only when one project exists at migration time; with ≥2 projects it
|
||||
falls back to the *project id*. The registry mirror expects the default
|
||||
project to own the collection id 'default'. On an upgrade whose DB already
|
||||
held ≥2 projects when 029 ran, the default project's collection is therefore
|
||||
named after the project (e.g. 'ohm'), and the next mirror would INSERT a
|
||||
second, empty 'default' collection. `reconcile_default_collection_id` heals
|
||||
the divergence at startup, before the mirror, so the mirror merges instead of
|
||||
duplicating. This is the collection-grain twin of `restamp_default_project`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import app.db as db
|
||||
from app import projects, registry
|
||||
|
||||
_TWO_PROJECT_REGISTRY = """
|
||||
deployment:
|
||||
name: Open Human Model
|
||||
tagline: t
|
||||
projects:
|
||||
- id: ohm
|
||||
name: Open Human Model
|
||||
type: document
|
||||
content_repo: ohm-content
|
||||
visibility: public
|
||||
- id: ecomm
|
||||
name: Ecomm
|
||||
type: bdd
|
||||
content_repo: ecomm-content
|
||||
visibility: public
|
||||
"""
|
||||
|
||||
|
||||
def _collection_ids_for(conn, project_id):
|
||||
return {r["id"] for r in conn.execute(
|
||||
"SELECT id FROM collections WHERE project_id = ?", (project_id,))}
|
||||
|
||||
|
||||
class _Cfg:
|
||||
def __init__(self, path, default_id):
|
||||
self.database_path = path
|
||||
self.default_project_id = default_id
|
||||
|
||||
|
||||
def _divergent_multiproject(monkeypatch, default_id="ohm"):
|
||||
"""A DB in the post-029 divergent state: the default project ('ohm') owns a
|
||||
collection whose id is the project id (the 029 ≥2-projects seed), a second
|
||||
project ('ecomm') owns its own collection, and NO 'default' collection
|
||||
exists. Entry rows point at the divergent collection_id='ohm'."""
|
||||
path = str(Path(tempfile.mkdtemp()) / "t.db")
|
||||
cfg = _Cfg(path, default_id)
|
||||
db.run_migrations(cfg) # seeds bootstrap 'default' project + 'default' collection
|
||||
monkeypatch.setattr(db, "_CONN", db.connect(path))
|
||||
conn = db.conn()
|
||||
# Drop the single-project bootstrap seed and rebuild the divergent
|
||||
# multi-project state 029 would have produced on an upgrade.
|
||||
conn.execute("DELETE FROM collections")
|
||||
conn.execute("DELETE FROM projects")
|
||||
conn.execute("INSERT INTO projects (id,name,content_repo,visibility) "
|
||||
"VALUES ('ohm','Open Human Model','ohm-content','public')")
|
||||
conn.execute("INSERT INTO projects (id,name,content_repo,visibility) "
|
||||
"VALUES ('ecomm','Ecomm','ecomm-content','public')")
|
||||
# 029 ≥2-projects seed: collection id == project id.
|
||||
conn.execute("INSERT INTO collections (id,project_id,type,subfolder,initial_state,visibility,name) "
|
||||
"VALUES ('ohm','ohm','document','','super-draft','public','Open Human Model')")
|
||||
conn.execute("INSERT INTO collections (id,project_id,type,subfolder,initial_state,visibility,name) "
|
||||
"VALUES ('ecomm','ecomm','bdd','ecomm','super-draft','public','Ecomm')")
|
||||
conn.execute("INSERT INTO users (id,gitea_login,display_name,role) VALUES (1,'a','A','contributor')")
|
||||
# Entry data for the default project lives in the divergent 'ohm' collection.
|
||||
conn.execute("INSERT INTO cached_rfcs (slug,title,state,collection_id) VALUES ('human','Human','active','ohm')")
|
||||
conn.execute("INSERT INTO rfc_collaborators (rfc_slug,user_id,role_in_rfc,collection_id) "
|
||||
"VALUES ('human',1,'contributor','ohm')")
|
||||
conn.execute("INSERT INTO stars (user_id,rfc_slug,collection_id) VALUES (1,'human','ohm')")
|
||||
# The 'ecomm' collection has its own entry.
|
||||
conn.execute("INSERT INTO cached_rfcs (slug,title,state,collection_id) VALUES ('cart','Cart','active','ecomm')")
|
||||
return cfg, conn
|
||||
|
||||
|
||||
def test_reconcile_renames_divergent_default_collection_to_default(monkeypatch):
|
||||
cfg, conn = _divergent_multiproject(monkeypatch, default_id="ohm")
|
||||
projects.reconcile_default_collection_id(cfg)
|
||||
# The default project's collection id is now the canonical 'default'.
|
||||
assert conn.execute("SELECT 1 FROM collections WHERE id='ohm'").fetchone() is None
|
||||
row = conn.execute("SELECT project_id FROM collections WHERE id='default'").fetchone()
|
||||
assert row is not None and row["project_id"] == "ohm"
|
||||
# Entry rows cascaded onto 'default'.
|
||||
assert conn.execute("SELECT collection_id FROM cached_rfcs WHERE slug='human'").fetchone()["collection_id"] == "default"
|
||||
assert conn.execute("SELECT collection_id FROM rfc_collaborators WHERE rfc_slug='human'").fetchone()["collection_id"] == "default"
|
||||
assert conn.execute("SELECT collection_id FROM stars WHERE rfc_slug='human'").fetchone()["collection_id"] == "default"
|
||||
# The non-default 'ecomm' collection is untouched (mirror + 029 agree on it).
|
||||
assert conn.execute("SELECT 1 FROM collections WHERE id='ecomm'").fetchone() is not None
|
||||
assert conn.execute("SELECT collection_id FROM cached_rfcs WHERE slug='cart'").fetchone()["collection_id"] == "ecomm"
|
||||
# FK integrity intact after the rename.
|
||||
assert conn.execute("PRAGMA foreign_key_check").fetchall() == []
|
||||
|
||||
|
||||
def test_reconcile_is_idempotent(monkeypatch):
|
||||
cfg, conn = _divergent_multiproject(monkeypatch, default_id="ohm")
|
||||
projects.reconcile_default_collection_id(cfg)
|
||||
projects.reconcile_default_collection_id(cfg) # second call: already aligned → no-op
|
||||
assert conn.execute("SELECT project_id FROM collections WHERE id='default'").fetchone()["project_id"] == "ohm"
|
||||
assert conn.execute("SELECT COUNT(*) c FROM cached_rfcs WHERE collection_id='default'").fetchone()["c"] == 1
|
||||
|
||||
|
||||
def test_reconcile_noop_when_default_id_is_literal_default(monkeypatch):
|
||||
# Single-project deployment, no DEFAULT_PROJECT_ID: 029 already seeded
|
||||
# 'default' and the mirror agrees — nothing to reconcile.
|
||||
path = str(Path(tempfile.mkdtemp()) / "t.db")
|
||||
cfg = _Cfg(path, "") # resolves to 'default'
|
||||
db.run_migrations(cfg)
|
||||
monkeypatch.setattr(db, "_CONN", db.connect(path))
|
||||
conn = db.conn()
|
||||
projects.reconcile_default_collection_id(cfg)
|
||||
assert conn.execute("SELECT 1 FROM collections WHERE id='default'").fetchone() is not None
|
||||
|
||||
|
||||
def test_mirror_duplicates_without_reconcile(monkeypatch):
|
||||
# Demonstrates the bug: the mirror on the divergent state inserts a SECOND
|
||||
# 'default' collection for the default project (alongside the 029 'ohm').
|
||||
cfg, conn = _divergent_multiproject(monkeypatch, default_id="ohm")
|
||||
doc = registry.parse_registry(_TWO_PROJECT_REGISTRY)
|
||||
registry.apply_registry(doc, registry_sha="s1", default_id="ohm")
|
||||
assert _collection_ids_for(conn, "ohm") == {"ohm", "default"} # duplicate!
|
||||
|
||||
|
||||
def test_reconcile_then_mirror_merges_no_duplicate(monkeypatch):
|
||||
# With the fix: reconcile before the mirror → the mirror merges onto the
|
||||
# canonical 'default' collection; the default project owns exactly one.
|
||||
cfg, conn = _divergent_multiproject(monkeypatch, default_id="ohm")
|
||||
projects.reconcile_default_collection_id(cfg)
|
||||
doc = registry.parse_registry(_TWO_PROJECT_REGISTRY)
|
||||
registry.apply_registry(doc, registry_sha="s1", default_id="ohm")
|
||||
assert _collection_ids_for(conn, "ohm") == {"default"}
|
||||
assert _collection_ids_for(conn, "ecomm") == {"ecomm"}
|
||||
# The default project's corpus entry is intact under 'default'.
|
||||
assert conn.execute("SELECT collection_id FROM cached_rfcs WHERE slug='human'").fetchone()["collection_id"] == "default"
|
||||
# The mirror refreshed the merged collection's metadata (type from registry).
|
||||
assert conn.execute("SELECT type FROM collections WHERE id='default'").fetchone()["type"] == "document"
|
||||
|
||||
|
||||
def test_reconcile_skips_when_default_collection_already_exists(monkeypatch):
|
||||
# A prior buggy mirror already created a 'default' collection alongside the
|
||||
# divergent 'ohm' one: don't auto-merge data — leave both for operator cleanup.
|
||||
cfg, conn = _divergent_multiproject(monkeypatch, default_id="ohm")
|
||||
conn.execute("INSERT INTO collections (id,project_id,type,subfolder,initial_state,visibility,name) "
|
||||
"VALUES ('default','ohm','document','','super-draft','public','dup')")
|
||||
projects.reconcile_default_collection_id(cfg)
|
||||
# Both still present (no destructive auto-merge).
|
||||
assert conn.execute("SELECT 1 FROM collections WHERE id='ohm'").fetchone() is not None
|
||||
assert conn.execute("SELECT 1 FROM collections WHERE id='default'").fetchone() is not None
|
||||
@@ -57,12 +57,15 @@ def test_rfc_owner_can_retire_and_entry_leaves_every_surface(app_with_fake_gitea
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["state"] == "retired"
|
||||
|
||||
# Meta entry on main: state retired, body + fields kept.
|
||||
meta = entry_mod.parse(
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
# §22.4a SLICE-4: the state flip lands in the metadata sidecar and the
|
||||
# `.md` is lazy-migrated to a clean body-only file (INV-2). Fields kept.
|
||||
import yaml as _yaml
|
||||
sc = _yaml.safe_load(
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.meta.yaml")]["content"]
|
||||
)
|
||||
assert meta.state == "retired"
|
||||
assert "carol" in meta.owners
|
||||
assert sc["state"] == "retired"
|
||||
assert "carol" in sc["owners"]
|
||||
assert "---" not in fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
|
||||
# Cache flipped; gone from the catalog.
|
||||
cached = db.conn().execute(
|
||||
@@ -177,11 +180,12 @@ def test_site_owner_can_retire_active_and_unretire_restores_active_with_id(app_w
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["state"] == "active"
|
||||
|
||||
meta = entry_mod.parse(
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
import yaml as _yaml
|
||||
sc = _yaml.safe_load(
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.meta.yaml")]["content"]
|
||||
)
|
||||
assert meta.state == "active"
|
||||
assert meta.id == "RFC-0042"
|
||||
assert sc["state"] == "active"
|
||||
assert sc["id"] == "RFC-0042"
|
||||
|
||||
cached = db.conn().execute(
|
||||
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'"
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
"""Slice S4 — invitation surfaces + role-aware empty states (@S4).
|
||||
|
||||
The acceptance gate for S4 is "every Part C.2 invitation scenario passes" (the
|
||||
design doc docs/design/2026-06-05-three-tier-projects-collections.md, §C.2,
|
||||
tagged @S4), plus the capability flags that drive the C.3 (@S4) role-aware
|
||||
empty states.
|
||||
|
||||
An Owner grants {owner, contributor} at a scope their reach covers — the
|
||||
project, or a single collection within it — to an existing account looked up by
|
||||
email. The grant writes a `memberships` row immediately and §15-notifies the
|
||||
grantee (no accept round-trip). Reach is bounded by the inviter's Owner reach;
|
||||
re-granting at a broader scope supersedes the narrower row; a `pending`
|
||||
deployment account's grant is recorded but confers no write.
|
||||
|
||||
Background (C.2): project "ohm" owns collections "model" (document) and
|
||||
"features" (bdd). eve is project Owner of ohm; dan is collection Owner of
|
||||
features only; ben is a project Contributor of ohm. ivy / jo / jet are
|
||||
grantees; kim is a pending deployment account.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers (mirror the S3 vertical's world-builders)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _su(user_id: int, login: str, role: str = "contributor", *, state: str = "granted"):
|
||||
from app import auth
|
||||
|
||||
return auth.SessionUser(
|
||||
user_id=user_id, gitea_id=user_id, gitea_login=login,
|
||||
display_name=login.capitalize(), email=f"{login}@test", avatar_url="",
|
||||
role=role, permission_state=state,
|
||||
)
|
||||
|
||||
|
||||
def _project(pid: str, visibility: str = "public", content_repo: str = "meta") -> None:
|
||||
from app import db
|
||||
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO projects (id, name, content_repo, visibility, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, datetime('now'))",
|
||||
(pid, pid.capitalize(), content_repo, visibility),
|
||||
)
|
||||
|
||||
|
||||
def _collection(cid: str, project_id: str, *, ctype: str = "document",
|
||||
visibility: str = "public", subfolder: str | None = None) -> None:
|
||||
from app import db
|
||||
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO collections "
|
||||
"(id, project_id, type, subfolder, initial_state, visibility, name, created_at, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, 'super-draft', ?, ?, datetime('now'), datetime('now'))",
|
||||
(cid, project_id, ctype, subfolder if subfolder is not None else cid,
|
||||
visibility, cid.capitalize()),
|
||||
)
|
||||
|
||||
|
||||
def _grant(scope_type: str, scope_id: str, user_id: int, role: str) -> None:
|
||||
from app import db
|
||||
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(scope_type, scope_id, user_id, role),
|
||||
)
|
||||
|
||||
|
||||
def _membership(user_id: int):
|
||||
"""The set of (scope_type, scope_id, role) rows a user holds."""
|
||||
from app import db
|
||||
|
||||
rows = db.conn().execute(
|
||||
"SELECT scope_type, scope_id, role FROM memberships WHERE user_id = ?",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
return {(r["scope_type"], r["scope_id"], r["role"]) for r in rows}
|
||||
|
||||
|
||||
def _seed_world() -> None:
|
||||
_project("ohm", "public")
|
||||
_collection("model", "ohm", ctype="document")
|
||||
_collection("features", "ohm", ctype="bdd")
|
||||
# the cast
|
||||
provision_user_row(user_id=2, login="ben", role="contributor")
|
||||
provision_user_row(user_id=4, login="dan", role="contributor")
|
||||
provision_user_row(user_id=5, login="eve", role="contributor")
|
||||
provision_user_row(user_id=10, login="ivy", role="contributor")
|
||||
provision_user_row(user_id=11, login="jo", role="contributor")
|
||||
provision_user_row(user_id=12, login="jet", role="contributor")
|
||||
provision_user_row(user_id=13, login="kim", role="contributor")
|
||||
_grant("project", "ohm", 5, "owner") # eve — project Owner
|
||||
_grant("collection", "features", 4, "owner") # dan — collection Owner only
|
||||
_grant("project", "ohm", 2, "contributor") # ben — project Contributor
|
||||
# kim is a pending deployment account.
|
||||
from app import db
|
||||
db.conn().execute(
|
||||
"UPDATE users SET permission_state = 'pending' WHERE id = 13"
|
||||
)
|
||||
|
||||
|
||||
def _login_eve(client) -> None:
|
||||
sign_in_as(client, user_id=5, gitea_login="eve", display_name="Eve", role="contributor")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# C.2 — invitation: who may invite whom, at which scope
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_c2_1_project_owner_invites_at_project_scope(app_with_fake_gitea):
|
||||
"""A project Owner grants at project scope; the grant covers every
|
||||
collection, and the grantee is §15-notified naming the project and role."""
|
||||
from app import auth
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login_eve(client)
|
||||
r = client.post("/api/projects/ohm/members",
|
||||
json={"email": "ivy@test", "role": "contributor"})
|
||||
assert r.status_code == 200, r.text
|
||||
# a membership row is written at scope project "ohm"
|
||||
assert ("project", "ohm", "contributor") in _membership(10)
|
||||
# ivy may propose in every collection of "ohm"
|
||||
ivy = _su(10, "ivy")
|
||||
assert auth.can_contribute_in_collection(ivy, "model") is True
|
||||
assert auth.can_contribute_in_collection(ivy, "features") is True
|
||||
# ivy receives a §15 notification naming the project and role
|
||||
sign_in_as(client, user_id=10, gitea_login="ivy", display_name="Ivy", role="contributor")
|
||||
inbox = client.get("/api/notifications").json()["items"]
|
||||
granted = [n for n in inbox if n["event_kind"] == "scope_role_granted"]
|
||||
assert granted, inbox
|
||||
assert "Ohm" in granted[0]["summary"]
|
||||
assert "RFC Contributor" in granted[0]["summary"]
|
||||
|
||||
|
||||
def test_c2_2_owner_invites_at_specific_collection(app_with_fake_gitea):
|
||||
"""A grant at a single collection scope reaches that collection only."""
|
||||
from app import auth
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login_eve(client)
|
||||
r = client.post("/api/projects/ohm/members",
|
||||
json={"email": "jo@test", "role": "contributor",
|
||||
"collection_id": "features"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert ("collection", "features", "contributor") in _membership(11)
|
||||
jo = _su(11, "jo")
|
||||
assert auth.can_contribute_in_collection(jo, "features") is True
|
||||
assert auth.can_contribute_in_collection(jo, "model") is False
|
||||
|
||||
|
||||
def test_c2_3_invitation_reach_bounded_by_inviter_scope(app_with_fake_gitea):
|
||||
"""A collection Owner may invite within that collection, but is not offered
|
||||
(is refused) the control to invite at the project or globally."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
# dan is Owner at collection "features" only.
|
||||
sign_in_as(client, user_id=4, gitea_login="dan", display_name="Dan", role="contributor")
|
||||
# may grant at his collection
|
||||
ok = client.post("/api/projects/ohm/members",
|
||||
json={"email": "ivy@test", "role": "contributor",
|
||||
"collection_id": "features"})
|
||||
assert ok.status_code == 200, ok.text
|
||||
# but not at the project scope
|
||||
no = client.post("/api/projects/ohm/members",
|
||||
json={"email": "ivy@test", "role": "contributor"})
|
||||
assert no.status_code == 403, no.text
|
||||
# the capability flags the UI reads agree: no project invite, yes collection
|
||||
proj = client.get("/api/projects/ohm/collections").json()["viewer"]
|
||||
assert proj["can_invite"] is False
|
||||
col = client.get("/api/projects/ohm/collections/features").json()["viewer"]
|
||||
assert col["can_invite"] is True
|
||||
|
||||
|
||||
def test_c2_4_contributors_do_not_manage_membership(app_with_fake_gitea):
|
||||
"""An RFC Contributor (project- or collection-scoped) holds no invite
|
||||
capability and the grant endpoints refuse them."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
# ben is a project *Contributor* on ohm.
|
||||
sign_in_as(client, user_id=2, gitea_login="ben", display_name="Ben", role="contributor")
|
||||
no_proj = client.post("/api/projects/ohm/members",
|
||||
json={"email": "ivy@test", "role": "contributor"})
|
||||
assert no_proj.status_code == 403, no_proj.text
|
||||
no_col = client.post("/api/projects/ohm/members",
|
||||
json={"email": "ivy@test", "role": "contributor",
|
||||
"collection_id": "features"})
|
||||
assert no_col.status_code == 403, no_col.text
|
||||
# no invite control surfaced anywhere
|
||||
assert client.get("/api/projects/ohm/collections").json()["viewer"]["can_invite"] is False
|
||||
assert client.get("/api/projects/ohm/collections/features").json()["viewer"]["can_invite"] is False
|
||||
|
||||
|
||||
def test_c2_5_no_grant_at_parent_revoke_at_child_option(app_with_fake_gitea):
|
||||
"""The grant surface offers only role + scope (project or one collection);
|
||||
there is no way to grant at the project yet carve out a child collection —
|
||||
a project grant reaches every collection, full stop."""
|
||||
from app import auth
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login_eve(client)
|
||||
# the only knobs are role and an optional single collection_id; an
|
||||
# "exclude" field has no effect (it is not part of the contract).
|
||||
r = client.post("/api/projects/ohm/members",
|
||||
json={"email": "ivy@test", "role": "contributor",
|
||||
"exclude_collection_id": "features"})
|
||||
assert r.status_code == 200, r.text
|
||||
# the project grant still reaches the supposedly-excluded collection
|
||||
ivy = _su(10, "ivy")
|
||||
assert auth.can_contribute_in_collection(ivy, "features") is True
|
||||
|
||||
|
||||
def test_c2_6_broader_scope_supersedes_narrower(app_with_fake_gitea):
|
||||
"""Re-granting at a broader scope removes the subsumed narrower row; the
|
||||
grantee holds the role across the whole project."""
|
||||
from app import auth
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_grant("collection", "features", 12, "contributor") # jet starts narrow
|
||||
_login_eve(client)
|
||||
r = client.post("/api/projects/ohm/members",
|
||||
json={"email": "jet@test", "role": "contributor"})
|
||||
assert r.status_code == 200, r.text
|
||||
rows = _membership(12)
|
||||
# the project grant is present…
|
||||
assert ("project", "ohm", "contributor") in rows
|
||||
# …and the redundant collection-scope row is gone (subsumed)
|
||||
assert ("collection", "features", "contributor") not in rows
|
||||
jet = _su(12, "jet")
|
||||
assert auth.can_contribute_in_collection(jet, "model") is True
|
||||
assert auth.can_contribute_in_collection(jet, "features") is True
|
||||
|
||||
|
||||
def test_c2_6b_narrower_stronger_role_is_not_subtracted(app_with_fake_gitea):
|
||||
"""No negative override: a child Owner grant survives a parent Contributor
|
||||
grant (the stronger collection role is kept)."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_grant("collection", "features", 12, "owner") # jet is collection Owner
|
||||
_login_eve(client)
|
||||
r = client.post("/api/projects/ohm/members",
|
||||
json={"email": "jet@test", "role": "contributor"})
|
||||
assert r.status_code == 200, r.text
|
||||
rows = _membership(12)
|
||||
assert ("project", "ohm", "contributor") in rows
|
||||
# the stronger collection-Owner row is NOT pruned by a weaker project grant
|
||||
assert ("collection", "features", "owner") in rows
|
||||
|
||||
|
||||
def test_c2_7_pending_account_grant_confers_no_write(app_with_fake_gitea):
|
||||
"""A grant to a pending deployment account is recorded but confers no write
|
||||
until the account is granted at the deployment (§6)."""
|
||||
from app import auth
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login_eve(client)
|
||||
r = client.post("/api/projects/ohm/members",
|
||||
json={"email": "kim@test", "role": "contributor"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["pending"] is True
|
||||
# the grant row is recorded…
|
||||
assert ("project", "ohm", "contributor") in _membership(13)
|
||||
# …but confers no write while pending (the §6 admission floor)
|
||||
kim = _su(13, "kim", state="pending")
|
||||
assert auth.effective_scope_role(kim, "model") is None
|
||||
assert auth.can_contribute_in_collection(kim, "model") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# C.3 (@S4) — the capability flags behind the role-aware empty states
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_c3_3_project_owner_sees_create_first_collection_capability(app_with_fake_gitea):
|
||||
"""C3.3: a project Owner landing on an empty project may create a
|
||||
collection — the flag the 'Create your first collection' CTA reads."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login_eve(client)
|
||||
caps = client.get("/api/projects/ohm/collections").json()["viewer"]
|
||||
assert caps["can_create_collection"] is True
|
||||
assert caps["role"] == "owner"
|
||||
|
||||
|
||||
def test_c3_4_contributor_without_create_rights_has_no_create_capability(app_with_fake_gitea):
|
||||
"""C3.4: a contributor whose only grant is at a collection elsewhere has no
|
||||
create-collection capability — the empty directory shows no create action."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
# dan holds only a collection-scope grant (features); no project create right.
|
||||
sign_in_as(client, user_id=4, gitea_login="dan", display_name="Dan", role="contributor")
|
||||
caps = client.get("/api/projects/ohm/collections").json()["viewer"]
|
||||
assert caps["can_create_collection"] is False
|
||||
|
||||
|
||||
def test_c3_5_collection_contributor_sees_propose_first_capability(app_with_fake_gitea):
|
||||
"""C3.5: a collection contributor landing on an empty collection may propose
|
||||
— the flag the 'Propose the first entry' CTA reads; an anonymous reader may
|
||||
not (the sign-in prompt path, already shipped in S2)."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_grant("collection", "model", 10, "contributor") # ivy contributes in model
|
||||
sign_in_as(client, user_id=10, gitea_login="ivy", display_name="Ivy", role="contributor")
|
||||
caps = client.get("/api/projects/ohm/collections/model").json()["viewer"]
|
||||
assert caps["can_contribute"] is True
|
||||
# anonymous reader: no propose capability
|
||||
client.cookies.clear()
|
||||
caps_anon = client.get("/api/projects/ohm/collections/model").json()["viewer"]
|
||||
assert caps_anon["can_contribute"] is False
|
||||
@@ -0,0 +1,141 @@
|
||||
"""§22.12 S6 — per-collection model universe.
|
||||
|
||||
A collection's `.collection.yaml` may carry an `enabled_models` list that
|
||||
NARROWS its project's universe, which in turn narrows the deployment
|
||||
ENABLED_MODELS. The resolution chain (extending §6.6/§6.7) is:
|
||||
|
||||
funder ∩ per-entry models ∩ collection universe ∩ project universe
|
||||
(operator providers = the ceiling)
|
||||
|
||||
These tests prove (1) the manifest parser reads `enabled_models`, (2) the
|
||||
mirror stores it on the collection row, (3) the resolver narrows the base
|
||||
universe by project then collection, with absent = inherit and [] = opt-out,
|
||||
and (4) the collection API surfaces the collection's own narrowing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import db, models_resolver, registry
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
app_with_fake_gitea, provision_user_row, sign_in_as, tmp_env,
|
||||
)
|
||||
from test_rfc_view_vertical import FakeProvider, seed_active_rfc # noqa: F401
|
||||
|
||||
|
||||
def _install_two_providers(app) -> None:
|
||||
app.state.providers.clear()
|
||||
app.state.providers["claude"] = FakeProvider("TITLE: A\nDESCRIPTION: B")
|
||||
app.state.providers["gemini"] = FakeProvider("TITLE: G\nDESCRIPTION: H")
|
||||
|
||||
|
||||
def _set_project_models(project_id: str, models) -> None:
|
||||
cfg = {} if models is None else {"enabled_models": models}
|
||||
db.conn().execute(
|
||||
"UPDATE projects SET config_json = ? WHERE id = ?",
|
||||
(json.dumps(cfg), project_id),
|
||||
)
|
||||
|
||||
|
||||
def _set_collection_models(collection_id: str, models) -> None:
|
||||
cfg = None if models is None else json.dumps({"enabled_models": models})
|
||||
db.conn().execute(
|
||||
"UPDATE collections SET config_json = ? WHERE id = ?",
|
||||
(cfg, collection_id),
|
||||
)
|
||||
|
||||
|
||||
# ── manifest parsing ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_manifest_parses_enabled_models_into_config():
|
||||
ce = registry.parse_collection_manifest(
|
||||
"type: bdd\nname: Features\nenabled_models: [claude, gemini]\n"
|
||||
)
|
||||
assert ce.config.get("enabled_models") == ["claude", "gemini"]
|
||||
|
||||
|
||||
def test_manifest_without_enabled_models_has_no_key():
|
||||
ce = registry.parse_collection_manifest("type: document\nname: Model\n")
|
||||
assert "enabled_models" not in ce.config
|
||||
|
||||
|
||||
def test_manifest_rejects_non_list_enabled_models():
|
||||
import pytest
|
||||
with pytest.raises(registry.RegistryError):
|
||||
registry.parse_collection_manifest("type: bdd\nenabled_models: claude\n")
|
||||
|
||||
|
||||
# ── resolver narrowing (the §22.12 chain) ──────────────────────────────────
|
||||
|
||||
|
||||
def test_resolver_inherits_operator_universe_when_no_scope_narrowing(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body="x")
|
||||
_install_two_providers(app)
|
||||
# No project/collection narrowing → full operator universe.
|
||||
resolved = models_resolver.resolve_models_for_rfc("ohm", app.state.providers)
|
||||
assert resolved == ["claude", "gemini"]
|
||||
|
||||
|
||||
def test_resolver_narrows_by_project_universe(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body="x")
|
||||
_install_two_providers(app)
|
||||
_set_project_models("default", ["gemini"])
|
||||
resolved = models_resolver.resolve_models_for_rfc("ohm", app.state.providers)
|
||||
assert resolved == ["gemini"]
|
||||
|
||||
|
||||
def test_resolver_collection_narrows_within_project(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body="x")
|
||||
_install_two_providers(app)
|
||||
# Project allows both; the collection narrows to claude only.
|
||||
_set_project_models("default", ["claude", "gemini"])
|
||||
_set_collection_models("default", ["claude"])
|
||||
resolved = models_resolver.resolve_models_for_rfc("ohm", app.state.providers)
|
||||
assert resolved == ["claude"]
|
||||
|
||||
|
||||
def test_resolver_collection_empty_list_opts_out(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body="x")
|
||||
_install_two_providers(app)
|
||||
_set_collection_models("default", []) # opt this collection out of AI
|
||||
resolved = models_resolver.resolve_models_for_rfc("ohm", app.state.providers)
|
||||
assert resolved == []
|
||||
|
||||
|
||||
def test_resolver_collection_cannot_widen_project(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body="x")
|
||||
_install_two_providers(app)
|
||||
# Project restricts to gemini; the collection naming claude+gemini
|
||||
# cannot re-add claude (narrowing only).
|
||||
_set_project_models("default", ["gemini"])
|
||||
_set_collection_models("default", ["claude", "gemini"])
|
||||
resolved = models_resolver.resolve_models_for_rfc("ohm", app.state.providers)
|
||||
assert resolved == ["gemini"]
|
||||
|
||||
|
||||
# ── API surfacing ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_collection_api_surfaces_enabled_models(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
|
||||
role="owner", email="ben@test")
|
||||
_set_collection_models("default", ["claude"])
|
||||
r = client.get("/api/projects/default/collections/default")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json().get("enabled_models") == ["claude"]
|
||||
@@ -0,0 +1,48 @@
|
||||
"""§22.4a S6 — the type-driven entry noun.
|
||||
|
||||
The displayed noun for an entry is a framework concept keyed on the
|
||||
collection's immutable type: document→"RFC", specification→"Spec", bdd→"Feature".
|
||||
The chrome reads it from the API rather than hardcoding "RFC", so the propose
|
||||
CTA + entry chrome name entries correctly per collection type with no
|
||||
per-deployment config.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import collections, db
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
app_with_fake_gitea, provision_user_row, sign_in_as, tmp_env,
|
||||
)
|
||||
|
||||
|
||||
def test_entry_noun_map():
|
||||
assert collections.entry_noun("document") == "RFC"
|
||||
assert collections.entry_noun("specification") == "Spec"
|
||||
assert collections.entry_noun("bdd") == "Feature"
|
||||
# An unknown/future type falls back to the generic noun, never label-less.
|
||||
assert collections.entry_noun("mystery") == "RFC"
|
||||
|
||||
|
||||
def test_collection_api_surfaces_entry_noun(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
|
||||
role="owner", email="ben@test")
|
||||
# Flip the default collection to a bdd type and confirm the noun follows.
|
||||
db.conn().execute("UPDATE collections SET type='bdd' WHERE id='default'")
|
||||
r = client.get("/api/projects/default/collections/default")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["entry_noun"] == "Feature"
|
||||
|
||||
|
||||
def test_directory_items_carry_entry_noun(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
db.conn().execute("UPDATE projects SET visibility='public' WHERE id='default'")
|
||||
db.conn().execute("UPDATE collections SET type='specification' WHERE id='default'")
|
||||
r = client.get("/api/deployment")
|
||||
assert r.status_code == 200, r.text
|
||||
item = next(p for p in r.json()["projects"] if p["id"] == "default")
|
||||
assert item["entry_noun"] == "Spec"
|
||||
@@ -0,0 +1,90 @@
|
||||
"""§22 S6 — two-project / multi-collection integrative pass.
|
||||
|
||||
Proves the S6 surface holds across a deployment with two projects, each owning
|
||||
distinct collections, with the new per-collection knobs (§22.12 enabled_models,
|
||||
§22.4a type noun) resolving independently per collection — no cross-project or
|
||||
cross-collection bleed.
|
||||
|
||||
Slug note: the resolver is slug-keyed, so this test uses distinct slugs across
|
||||
collections (the documented limitation — same-slug-across-collections model
|
||||
resolution is part of the broader collection-id-threading follow-up).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import collections, db, models_resolver
|
||||
from test_propose_vertical import app_with_fake_gitea, tmp_env # noqa: F401
|
||||
from test_rfc_view_vertical import FakeProvider # noqa: F401
|
||||
|
||||
|
||||
def _project(pid: str, visibility: str = "public") -> None:
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO projects (id, name, content_repo, visibility, config_json, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, datetime('now'))",
|
||||
(pid, pid.capitalize(), f"{pid}-content", visibility, None),
|
||||
)
|
||||
|
||||
|
||||
def _collection(cid: str, project_id: str, *, ctype: str, enabled_models=None) -> None:
|
||||
cfg = None if enabled_models is None else json.dumps({"enabled_models": enabled_models})
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO collections "
|
||||
"(id, project_id, type, subfolder, initial_state, visibility, name, config_json, "
|
||||
" created_at, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, 'super-draft', 'public', ?, ?, datetime('now'), datetime('now'))",
|
||||
(cid, project_id, ctype, cid, cid.capitalize(), cfg),
|
||||
)
|
||||
|
||||
|
||||
def _entry(slug: str, collection_id: str) -> None:
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO cached_rfcs (slug, title, state, collection_id) "
|
||||
"VALUES (?, ?, 'active', ?)",
|
||||
(slug, slug.upper(), collection_id),
|
||||
)
|
||||
|
||||
|
||||
def _three_providers(app) -> None:
|
||||
app.state.providers.clear()
|
||||
for k in ("claude", "gemini", "gpt"):
|
||||
app.state.providers[k] = FakeProvider("TITLE: A\nDESCRIPTION: B")
|
||||
|
||||
|
||||
def test_two_projects_multicollection_model_universe_isolated(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
_three_providers(app)
|
||||
# alpha: a document collection narrowed to claude.
|
||||
_project("alpha")
|
||||
_collection("alpha-docs", "alpha", ctype="document", enabled_models=["claude"])
|
||||
_entry("alpha-intro", "alpha-docs")
|
||||
# beta: two collections — a spec narrowed to gemini, a bdd left open.
|
||||
_project("beta")
|
||||
_collection("beta-specs", "beta", ctype="specification", enabled_models=["gemini"])
|
||||
_collection("beta-features", "beta", ctype="bdd") # no narrowing → all three
|
||||
_entry("beta-runtime", "beta-specs")
|
||||
_entry("beta-login", "beta-features")
|
||||
|
||||
# Each entry resolves against ITS collection's universe — no bleed.
|
||||
assert models_resolver.resolve_models_for_rfc("alpha-intro", app.state.providers) == ["claude"]
|
||||
assert models_resolver.resolve_models_for_rfc("beta-runtime", app.state.providers) == ["gemini"]
|
||||
assert models_resolver.resolve_models_for_rfc("beta-login", app.state.providers) == [
|
||||
"claude", "gemini", "gpt"
|
||||
]
|
||||
|
||||
|
||||
def test_two_projects_multicollection_type_noun_isolated(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
_project("alpha")
|
||||
_collection("alpha-docs", "alpha", ctype="document")
|
||||
_project("beta")
|
||||
_collection("beta-specs", "beta", ctype="specification")
|
||||
_collection("beta-features", "beta", ctype="bdd")
|
||||
|
||||
assert collections.get_collection("alpha-docs")["entry_noun"] == "RFC"
|
||||
assert collections.get_collection("beta-specs")["entry_noun"] == "Spec"
|
||||
assert collections.get_collection("beta-features")["entry_noun"] == "Feature"
|
||||
@@ -5,7 +5,7 @@
|
||||
#
|
||||
# while IFS='=' read -r k v; do
|
||||
# [ -n "$k" ] && case "$k" in \#*) ;; *) \
|
||||
# ohm-rfc-app-flotilla overlay set ohm-rfc-app "$k=$v" --preview ;; esac
|
||||
# flotilla-core overlay set <deployment> "$k=$v" --preview ;; esac
|
||||
# done < deploy/preview/preview.env.example
|
||||
#
|
||||
# CRITICAL (§15 / §3 invariant 1): a preview resolves ZERO real secret bytes.
|
||||
|
||||
@@ -90,6 +90,99 @@ the framework at that pin against your Gitea; the framework knows
|
||||
nothing about your specific deployment beyond what your `.env`
|
||||
files told it.
|
||||
|
||||
## The registry: projects and collections (§22)
|
||||
|
||||
From **v0.45.0** a deployment is **three tiers** — deployment →
|
||||
**project** → **RFC collection** — instead of one corpus. Most of
|
||||
the single-corpus setup above still applies (a deployment that wants
|
||||
one corpus runs the **N=1 case** unchanged), but two things move into
|
||||
**git-truth the framework mirrors**, exactly the way RFC bodies do.
|
||||
The binding model is [`SPEC.md` §22](../SPEC.md); this is the operator
|
||||
view of the file formats.
|
||||
|
||||
### The registry repo (`REGISTRY_REPO`) declares projects
|
||||
|
||||
`VITE_APP_NAME` and `META_REPO` are superseded. The framework now reads
|
||||
a **registry repo** — a dedicated repo under your Gitea org, named
|
||||
whatever you like, whose location you pass in `backend/.env` as
|
||||
`REGISTRY_REPO` (the framework fails loudly at startup if it is unset).
|
||||
Its root holds a `projects.yaml`:
|
||||
|
||||
```yaml
|
||||
# projects.yaml (registry repo root)
|
||||
deployment:
|
||||
name: Wiggleverse # deployment display name (was VITE_APP_NAME)
|
||||
tagline: A substrate for collaborative standardization
|
||||
projects:
|
||||
- id: ohm # url-stable slug, unique in the deployment → /p/ohm/
|
||||
name: Open Human Model
|
||||
content_repo: ohm-content # ONE repo under your org; collections live inside it
|
||||
visibility: public # gated | public | unlisted (§22.5)
|
||||
theme: { accent: "#5b5bd6" } # optional per-project token overrides
|
||||
enabled_models: [claude, gemini] # optional; falls back to ENABLED_MODELS
|
||||
```
|
||||
|
||||
Each project owns **exactly one content repo**. Adding, reconfiguring,
|
||||
or archiving a project is a PR against `projects.yaml`; the framework's
|
||||
webhook + reconciler mirror it into the `projects` cache table. Project
|
||||
**membership** is app state (it churns at user speed), not registry
|
||||
data. The deployment's display name and tagline come from here, served
|
||||
at runtime via `GET /api/deployment` — no rebuild needed to rename.
|
||||
|
||||
### The content repo declares collections via `.collection.yaml`
|
||||
|
||||
A project's collections are **typed subfolders** of its content repo,
|
||||
each carrying a `.collection.yaml` manifest the registry mirror reads:
|
||||
|
||||
```
|
||||
ohm-content/
|
||||
model/
|
||||
.collection.yaml # type: document
|
||||
rfcs/intro.md
|
||||
specs/
|
||||
.collection.yaml # type: specification
|
||||
rfcs/runtime.md
|
||||
features/
|
||||
.collection.yaml # type: bdd
|
||||
rfcs/login.md
|
||||
```
|
||||
|
||||
```yaml
|
||||
# ohm-content/model/.collection.yaml
|
||||
type: document # document | specification | bdd — IMMUTABLE once set
|
||||
visibility: gated # defaults to the project's; may only NARROW it
|
||||
initial_state: super-draft # super-draft | active — defaults from type
|
||||
name: The Model
|
||||
# enabled_models: [claude] # optional; may only narrow the project's universe
|
||||
```
|
||||
|
||||
`type` is fixed at creation (the framework refuses to change it on a
|
||||
later mirror). `visibility` may be as strict as or stricter than the
|
||||
project's, never looser (`public` < `unlisted` < `gated`); reading or
|
||||
writing a collection requires passing **both** the project and the
|
||||
collection gate. `enabled_models`, if present, narrows the project's
|
||||
model universe for that collection.
|
||||
|
||||
### Default project + default collection (the N=1 upgrade)
|
||||
|
||||
A deployment upgrading from a pre-§22 version is migrated automatically:
|
||||
its single corpus becomes one **default project** carrying one **default
|
||||
collection** (`id` `default`, subfolder = repo root), inheriting the old
|
||||
`type` / `initial_state` / visibility. Old URLs 308-redirect into the
|
||||
`/p/<project>/c/default/…` form, so existing links survive. Until you
|
||||
add a second project or collection the deployment is functionally
|
||||
identical to before, with one extra path segment. The ordered upgrade
|
||||
actions are the [`CHANGELOG.md`](../CHANGELOG.md) entry's upgrade-steps
|
||||
block (§20.4).
|
||||
|
||||
### Creating projects and collections in-app
|
||||
|
||||
Both tiers can also be created from the UI by an Owner — the action
|
||||
wraps a bot commit (a new content repo + `projects.yaml` entry for a
|
||||
project; a new subfolder + `.collection.yaml` for a collection), so
|
||||
everything still flows from git. Nothing becomes app state the mirror
|
||||
cannot rebuild.
|
||||
|
||||
## The two-repo working pattern
|
||||
|
||||
Once a deployment is live, day-to-day changes split across the
|
||||
|
||||
@@ -581,17 +581,28 @@ means the deployment runs and either gains a capability or provably loses none
|
||||
hidden from the public. **Completes:** `@S3` (all of C.1 — role usage,
|
||||
inheritance, union, no-negative-override).
|
||||
|
||||
- **S4 — Invitation surfaces + role-aware empty states.** The invite UI
|
||||
- **S4 — Invitation surfaces + role-aware empty states.** *(Shipped v0.43.0.)*
|
||||
The invite UI
|
||||
(Owner-only) granting Owner/RFC Contributor at a scope or any scope beneath it,
|
||||
with §15 notifications and the broader-scope-supersedes rule; the
|
||||
create-first-collection / propose-first empty states keyed to the actor's role.
|
||||
**Usable end-state:** an Owner invites collaborators at the right scope from
|
||||
the UI. **Completes:** `@S4` (all of C.2 — invitation; plus the project/
|
||||
collection empty states C3.3–C3.5).
|
||||
Modelled as a **direct grant** to an existing account looked up by email (the
|
||||
C.2 scenarios write the membership row immediately and §15-notify an existing
|
||||
user — no accept round-trip; inviting a not-yet-account email is out of S4
|
||||
scope, handled by the admin-create-invite path). **Usable end-state:** an Owner
|
||||
invites collaborators at the right scope from the UI. **Completes:** `@S4` (all
|
||||
of C.2 — invitation; plus the project/collection empty states C3.3–C3.5).
|
||||
|
||||
- **S5 — In-app create-project + the global directory.** The global-Owner
|
||||
- **S5 — In-app create-project + the global directory.** *(Shipped v0.44.0.)*
|
||||
The global-Owner
|
||||
**create-project** action (bot provisions a Gitea content repo + commits to
|
||||
`projects.yaml`); the deployment directory empty states. **Usable end-state:**
|
||||
`projects.yaml`); the deployment directory empty states. Modelled as a
|
||||
global-Owner gate (`auth.can_create_project`: a deployment owner/admin or an
|
||||
explicit `scope_type='global'` Owner grant) over `POST /api/projects`; the
|
||||
content repo defaults to `<id>-content` and is seeded with a `README.md` so
|
||||
`main` exists. The deployment payload gains `viewer.can_create_project` +
|
||||
`default_project_readable` so the directory renders the role-aware empty state
|
||||
rather than bouncing into an unreadable/absent default. **Usable end-state:**
|
||||
a global Owner stands up a new project end-to-end from the UI. **Completes:**
|
||||
`@S5` (the global-directory empty states C3.1–C3.2).
|
||||
|
||||
@@ -603,6 +614,15 @@ means the deployment runs and either gains a capability or provably loses none
|
||||
place). **Usable end-state:** the model is fully realized and merged into
|
||||
`SPEC.md`. **Completes:** type-specific scenarios (added in S6, beyond Part C's
|
||||
role focus).
|
||||
- *Shipped in S6 core (v0.45.0):* the SPEC merge, per-collection
|
||||
`enabled_models`, the type-driven entry noun (§22.4a item 2).
|
||||
- *Shipped as the S6 remainder (v0.46.0):* request-to-join + the
|
||||
cross-collection inbox (§22.8).
|
||||
- *Spec'd, not yet built — the last S6 item:* the per-type **frontmatter
|
||||
schemas** (§22.4a item 1) and **surfaces** (§22.4a item 3, the
|
||||
`specification` release-planning + `bdd` scenario/coverage views). The
|
||||
discovery/spec pass + BDD scenarios + slicing (S7a–S7c) are in
|
||||
[`2026-06-06-per-type-surfaces.md`](./2026-06-06-per-type-surfaces.md).
|
||||
|
||||
### Slice → scenario index (the inverse of the `@S<n>` tags)
|
||||
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
# Solution Design: Configurable Collection Metadata (clean-doc tagging)
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **Author(s)** | Ben Stull |
|
||||
| **Reviewers / approvers** | Ben Stull |
|
||||
| **Status** | `draft` |
|
||||
| **Version** | v0.1.6 |
|
||||
| **Source artifacts** | Reference modeled: retired **BDD Release Planner** (`wiggleverse/wiggleverse-ecomm-bdd-release-planner-app`, RETIRED 2026-06-04) · Related: [`2026-06-05-three-tier-projects-collections.md`](./2026-06-05-three-tier-projects-collections.md) (§22) · Corpus: ecomm Shopify-modeled BDD (`wiggleverse-ecomm-meta/research/shopify`, ~1,238 scenarios) · **Supersedes:** [`2026-06-06-per-type-surfaces.md`](./2026-06-06-per-type-surfaces.md) |
|
||||
|
||||
**Change log**
|
||||
|
||||
| Date | Version | Change | By |
|
||||
| --- | --- | --- | --- |
|
||||
| 2026-06-06 | v0.1.0 | Initial draft from discovery session OHM-0079.0 | Ben Stull |
|
||||
| 2026-06-06 | v0.1.1 | Value-only Executive Summary; add Pain Points | Ben Stull |
|
||||
| 2026-06-06 | v0.1.2 | Business Outcomes restated as business (adoption/diversity); Business Use Cases → solution-agnostic | Ben Stull |
|
||||
| 2026-06-06 | v0.1.3 | Supersede per-type-surfaces draft (harvest patterns; bdd coverage future; §22.4a amendment); split Business Actors / Product Personas | Ben Stull |
|
||||
| 2026-06-06 | v0.1.4 | Two-part restructure: §1 Business Context (solution-agnostic, 1.1–1.9) + §2 Solution Proposal; renumber | Ben Stull |
|
||||
| 2026-06-06 | v0.1.5 | Move Business Actors to §1.3 (define roles before Problem/Pain reference them) | Ben Stull |
|
||||
| 2026-06-06 | v0.1.6 | §7.1 execution convention — each slice is its own writing-plans→executing-plans coding session, plans just-in-time | Ben Stull |
|
||||
|
||||
---
|
||||
|
||||
## 1. Business Context
|
||||
|
||||
*The business lens — solution-agnostic throughout. No mechanism is proposed until §2.*
|
||||
|
||||
### 1.1 Executive Summary
|
||||
|
||||
A deployment's corpus is only as valuable as the ability of the people running it to prioritise it, navigate it, and act on it — and as valuable as the downstream tools that can read structured signal out of it. Today that value is stranded: operators and contributors can't rank what matters or find content by what matters, and the tools meant to plan and build from the corpus have nothing structured to consume. The value at stake is **lower-friction corpus planning** for operators and contributors, **broader adoption** by teams whose document types the platform couldn't previously serve, and **a corpus external tooling can consume without bespoke glue**. *(Value summary; the solution is proposed in §2.)*
|
||||
|
||||
### 1.2 Background
|
||||
|
||||
The framework hosts RFC standardization for multiple deployments. One deployment hosts the ecomm BDD corpus — ~1,238 Shopify-modeled scenarios, one markdown file per scenario, slugged by feature ID (`DD-FF-NNNN-slug`). A standalone **BDD Release Planner** previously let operators search that corpus, attach metadata (priority P0–P3, owner, status), cluster scenarios into named releases, and emit each release as a roadmap phase. §22 (three-tier projects/collections) absorbed the planner's *corpus hosting* into rfc-app (the corpus now runs as a `bdd` project on the RFC deployment) and the planner was retired — but its *annotation* half (priority/tags on scenarios, filtering, bulk assignment) was never rebuilt. Teams evaluating rfc-app for *other* document types often need structured attributes (a priority, a status, domain tags) the platform can't yet express — so they go elsewhere.
|
||||
|
||||
### 1.3 Business Actors / Roles
|
||||
|
||||
Real-world roles, **solution-agnostic** — they exist whether or not rfc-app does. They are defined here, before the Problem (§1.4) and Pain Points (§1.5) reference them; the Business Use Cases (§1.9) are about these roles, and the Product Personas (§3) map onto them.
|
||||
|
||||
| Role | Responsible for (in the business) |
|
||||
| --- | --- |
|
||||
| Standards owner | Owns an organization's RFC / standards / requirements process; decides what's tracked and how |
|
||||
| Release planner | Decides what work belongs in upcoming releases |
|
||||
| Requirements author | Proposes and curates the requirements (e.g. BDD scenarios) |
|
||||
| Requirements consumer | A person or downstream tool that plans or builds from the requirements |
|
||||
| Reader | Anyone navigating the corpus to find what's relevant to them |
|
||||
|
||||
### 1.4 Problem Statement
|
||||
|
||||
rfc-app cannot express or surface structured signal about its content. Tags are free-form strings with no filtering; there is no notion of priority or any other collection-defined attribute; the catalog is a flat list; and what little metadata exists is mixed into the top of every document. As a result, a corpus cannot be prioritised, navigated by attribute, planned in bulk, or cleanly consumed by downstream tools — and teams whose workflows depend on such attributes cannot adopt the platform at all.
|
||||
|
||||
### 1.5 Pain Points
|
||||
|
||||
| # | Pain | Who feels it | Cost / frequency today |
|
||||
| --- | --- | --- | --- |
|
||||
| PP-1 | Scenarios carry no priority, so triage and planning happen off-platform, in spreadsheets and memory | Release planner, contributor | Every planning cycle; signal lives off-platform and goes stale |
|
||||
| PP-2 | The catalog is a flat, unfilterable list — at ~1,200 scenarios, "show me the P0 checkout scenarios" is impractical | Reader, release planner | Every browse/triage; finding the right work is slow and error-prone |
|
||||
| PP-3 | Tags are free-form with no filtering payoff, so they're decorative and go unmaintained | Contributor | Ongoing; the one existing affordance rots |
|
||||
| PP-4 | Annotating many scenarios means opening many PRs, so bulk planning has no home in the tool | Release planner | Every batch; the core planning gesture is effectively impossible |
|
||||
| PP-5 | rfc-app metadata clutters the top of every document, hurting readability and making the corpus awkward to consume cleanly | Reader, downstream consumer | Every read; every downstream integration |
|
||||
| PP-6 | Downstream tools have no structured signal to read — the retired planner's capability left a gap | Downstream consumer | Continuous since the planner's retirement |
|
||||
| PP-7 | Teams whose document types need structured attributes can't model them, so they don't adopt rfc-app | Prospective adopter (org/team) | Every evaluation that ends in "not yet" |
|
||||
|
||||
### 1.6 Targeted Business Outcomes
|
||||
|
||||
Business outcomes for rfc-app as a platform — adoption, reach, and diversity of use — **not** solution outputs. (Whether documents carry a priority is a solution output, tracked as a slice's Definition of Done in §7, not here.)
|
||||
|
||||
| Outcome | Success metric | Baseline → Target | Guardrail (must not regress) | How / when measured |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Teams blocked by missing structured attributes now adopt rfc-app | # organizations on rfc-app; # active users | internal deployments only → external orgs onboard | existing deployments don't churn | deployment registry + usage analytics; quarterly |
|
||||
| The platform hosts a wider variety of workflows and document types | # distinct document/collection types & field schemas in use | today's handful → broader mix | existing types' experience unchanged | type/schema census; quarterly |
|
||||
| Corpus planning happens on-platform rather than in side tools | share of prioritisation/planning done in rfc-app vs spreadsheets | largely off-platform → on-platform | — | operator interviews + usage signals; quarterly |
|
||||
|
||||
### 1.7 Scope (business)
|
||||
|
||||
- **In scope:** the corpus can carry per-item importance and categorisation; people can find items by those attributes; the signal is captured durably and is consumable by other people and tools; teams with new document types can express the attributes their workflow needs.
|
||||
- **Out of scope (business):** deciding *what* a given deployment's priorities or categories should be (that's the deployment's editorial choice); release sequencing and ship tracking as a business process (stays a downstream/operator concern).
|
||||
- **Non-goals:** modelling "releases" as a first-class business object inside the platform.
|
||||
|
||||
*(Solution-specific scope/non-goals are in §2.)*
|
||||
|
||||
### 1.8 Assumptions · Constraints · Dependencies
|
||||
|
||||
- **Assumptions:** git remains the content source of truth and downstream consumers can read the corpus from git; the BDD grain is one markdown file per scenario (already true for the ecomm corpus).
|
||||
- **Constraints:** rfc-app is a framework hosting multiple deployments — any change must be **mechanical and non-breaking**, with §20 changelog/upgrade-steps; the hard secrets rule (§6.3) holds; edits must respect scope-role authorization (§22 Part B / S3); the §22.4a "engine unchanged" rule holds (INV-8).
|
||||
- **Dependencies:** the S3 scope-role resolver (`auth.effective_scope_role`); the existing git write-through used by `edit-meta` (§9.5); the §22 collection model; the binding `SPEC.md` §22.4a contract, which §2's solution amends (§7 SLICE-0).
|
||||
|
||||
### 1.9 Business Use Cases
|
||||
|
||||
Solution-agnostic: what an actor (§1.3) wants to accomplish, *why* (value), and what *success* looks like — **no reference to any product**. Each could be satisfied by a person by hand before any software. Form: "As a … I can … so that …".
|
||||
|
||||
**BUC-1 — As a release planner, I can prioritise the requirements in a body of work, so that I can decide what belongs in upcoming releases.**
|
||||
```gherkin
|
||||
Scenario: BUC-1 — Prioritise to plan releases
|
||||
Given a body of requirements of varying importance
|
||||
When the planner weighs which matter most
|
||||
Then they hold a ranking of those requirements by importance
|
||||
And can decide a release's contents from it
|
||||
```
|
||||
- **Acceptance:** the planner can select and justify the next release's contents from the relative importance of the work.
|
||||
|
||||
**BUC-2 — As a planner facing a large body of requirements, I can organise and triage it within a normal working session, so that planning actually gets done rather than deferred or improvised.**
|
||||
```gherkin
|
||||
Scenario: BUC-2 — Triage at scale
|
||||
Given more requirements than can be weighed one at a time
|
||||
When the planner ranks and groups them in bulk
|
||||
Then the body of work reflects those decisions without per-item drudgery
|
||||
```
|
||||
- **Acceptance:** a planner moves from an unsorted corpus to a prioritised plan in one sitting.
|
||||
|
||||
**BUC-3 — As a team, I want the importance and categorisation of our requirements captured durably and shareably, so that other people and tools can plan from it without re-deriving it.**
|
||||
```gherkin
|
||||
Scenario: BUC-3 — Durable, shareable signal
|
||||
Given requirements that have been weighed and categorised
|
||||
When someone or something else needs to plan from them
|
||||
Then they can read what matters and why without asking the original author
|
||||
```
|
||||
- **Acceptance:** a second party — person or tool — can pick up the work and plan from it unaided.
|
||||
|
||||
**BUC-4 — As a team with a specialised body of documents, I can capture the attributes that make them actionable (importance, status, category), so that I can manage that work the way my domain requires.**
|
||||
```gherkin
|
||||
Scenario: BUC-4 — Manage a domain's work on its own terms
|
||||
Given documents whose usefulness depends on domain-specific attributes
|
||||
When the team records and works with those attributes
|
||||
Then they can run their workflow with the distinctions it depends on
|
||||
```
|
||||
- **Acceptance:** the team can capture and act on the distinctions their domain requires — success is them choosing to manage the work this way.
|
||||
|
||||
**BUC-5 — As someone consuming a large corpus, I can find the items that matter to my current purpose, so that I act on the right things instead of wading through everything.**
|
||||
```gherkin
|
||||
Scenario: BUC-5 — Find what matters
|
||||
Given a large body of items
|
||||
When the consumer looks for the important ones for their task
|
||||
Then they can locate them quickly
|
||||
```
|
||||
- **Acceptance:** a person narrows a large corpus to the relevant, important subset for their task.
|
||||
|
||||
---
|
||||
|
||||
## 2. Solution Proposal
|
||||
|
||||
**The solution is to build it into rfc-app.** Give every collection a small, declared **field schema** (in its `.collection.yaml`) so it can carry structured metadata — priority, tags, and any custom fields the deployment defines. Store each entry's values in a **clean sidecar** file so the document body stays pure prose. rfc-app then **renders those fields as forms, filters the catalog by them (faceted, with counts), and lets authorized users tag in single and bulk gestures** committed straight to git; downstream tools read the values from the sidecars directly. It is one generic mechanism — tags and priority are just *fields* — not per-type special-casing and not a bespoke "release" entity.
|
||||
|
||||
**Why a software solution (and not a manual one).** A non-build alternative — operators maintaining priorities/tags in a shared spreadsheet — was considered and rejected: it leaves the corpus unfilterable in-tool (PP-2), keeps documents and the side-sheet out of sync, produces no durable git-readable signal for downstream tools (PP-5/PP-6), and does nothing for the adoption outcome (§1.6, PP-7). The value only lands if the structure lives with the content.
|
||||
|
||||
**Solution-specific scope.** *Out:* release ordering, ship status, roadmap emission, the `specification` release-planning surface — all downstream, reading sidecars from git. In-app management of field definitions (edit `.collection.yaml` in git for v1); corpus-wide tag rename/merge/delete; sub-document grain; a whole-corpus export endpoint. *Future (recorded, not v1):* a **bdd coverage surface** — a `verifies`-style **`ref` field type** plus a read-derived view mapping features to the spec sections they exercise (harvested from the superseded per-type-surfaces draft); deferred pending §9 Q4. This solution **amends the binding `SPEC.md` §22.4a contract** (§7 SLICE-0).
|
||||
|
||||
*(The Product and Engineering sections below — §§3–7 — elaborate this build. They would be replaced by an operational plan if the chosen solution were non-software.)*
|
||||
|
||||
---
|
||||
|
||||
## 3. Product Personas
|
||||
|
||||
rfc-app's user types — each an embodiment of one or more Business Roles (§1.3). The Product Use Cases (§4) are about these personas.
|
||||
|
||||
| Product persona | In rfc-app | Maps to business role(s) |
|
||||
| --- | --- | --- |
|
||||
| Collection Owner | scope-role Owner; declares the collection's `fields:` schema (edits `.collection.yaml`) | Standards owner |
|
||||
| Contributor | scope-role contributor; sets metadata (single + bulk), proposes/curates entries | Requirements author; Release planner |
|
||||
| Reader | viewer; browses and filters the catalog | Reader |
|
||||
| Downstream consumer | an external system reading sidecars + `.collection.yaml` from git | Requirements consumer |
|
||||
|
||||
## 4. Product Use Cases
|
||||
|
||||
```gherkin
|
||||
Scenario: PUC-1 — Set priority/tags on a scenario (realizes BUC-1, BUC-4)
|
||||
Given I am a Contributor viewing a scenario whose collection defines priority and tags
|
||||
When I choose P0 in the priority control and add the tag "checkout"
|
||||
Then the metadata panel reflects P0 and the checkout tag
|
||||
And the change is committed directly to the scenario's sidecar
|
||||
|
||||
Scenario: PUC-2 — Bulk tag/untag from the catalog (realizes BUC-2)
|
||||
Given I have multi-selected several scenarios in the catalog
|
||||
When I choose "Set priority → P1" from the bulk action bar
|
||||
Then every selected scenario shows P1
|
||||
And the bulk change is one commit
|
||||
|
||||
Scenario: PUC-3 — Filter the catalog by facet (realizes BUC-5, BUC-1)
|
||||
Given the left pane shows faceted filters generated from the collection schema
|
||||
When I check Priority P0 and tag "checkout"
|
||||
Then the catalog shows only scenarios matching both
|
||||
And each facet value shows its result count
|
||||
|
||||
Scenario: PUC-4 — A Collection Owner declares fields (realizes BUC-4)
|
||||
Given a Collection Owner edits .collection.yaml to add a priority enum field
|
||||
When the collection is re-ingested
|
||||
Then the priority filter and the priority form control appear automatically
|
||||
|
||||
Scenario: PUC-5 — Migrate a collection to clean docs (product-only; enables BUC-3)
|
||||
Given a collection whose docs still carry top-of-doc frontmatter
|
||||
When the operator runs the frontmatter→sidecar migration
|
||||
Then each doc body becomes pure prose and a sidecar holds its metadata
|
||||
And rfc-app reads the collection identically before and after
|
||||
|
||||
Scenario: PUC-6 — A malformed entry is visibly fixable (realizes BUC-3)
|
||||
Given a stored entry whose metadata fails its collection's schema
|
||||
When the catalog renders
|
||||
Then the entry still loads (read never hard-fails)
|
||||
And it is flagged "malformed metadata" so a Contributor can fix it
|
||||
```
|
||||
|
||||
## 5. UX Layout
|
||||
|
||||
### 5.1 Screen: Catalog (left pane) (serves PUC-3, PUC-6)
|
||||
|
||||
- **Purpose:** browse and filter a collection's entries.
|
||||
- **Layout (top → bottom):** full-text search (existing); **faceted filter groups** (one per schema field + state): each a collapsible group with per-value **result counts** and multi-select checkboxes; `tags`-type fields include a "filter values…" search box to stay usable at 30+ values.
|
||||
- **States:** happy: facets with counts · empty: "no entries match" + clear-filters · loading: skeleton facets · error: retry · **malformed:** entries failing their schema carry a fixable marker (parallel to §22.4c `unreviewed`) and are filterable.
|
||||
|
||||
### 5.2 Screen: Scenario detail — metadata panel (serves PUC-1)
|
||||
|
||||
- **Purpose:** view/edit one entry's metadata.
|
||||
- **Layout:** one control per schema field — `enum` → single-select; `tags` → removable chips + add-tag input (with existing AI suggest); `text` → text input. The body renders below as pure prose; metadata never appears inline.
|
||||
- **States:** read (no edit role) shows values · edit (authorized) shows controls · saving: spinner · error: field-level validation message.
|
||||
|
||||
### 5.3 Screen: Catalog — bulk action bar (serves PUC-2)
|
||||
|
||||
- **Purpose:** apply a field value to many entries at once.
|
||||
- **Layout:** selecting ≥1 row reveals a sticky bar: "*N* selected · Set priority ▾ · Add tag ▾ · Remove tag ▾ · Clear". Applying commits once.
|
||||
- **States:** none selected: hidden · applying: progress · partial failure: toast naming entries that failed validation, others applied.
|
||||
|
||||
## 6. Technical Design
|
||||
|
||||
### 6.1 Invariants
|
||||
|
||||
- **INV-1:** The sidecar (`<slug>.meta.yaml`) is the source of truth for entry metadata; `cached_rfcs` is a derived index, fully rebuildable from git.
|
||||
- **INV-2:** A document body (`.md`) never contains rfc-app metadata once migrated; metadata lives only in the sidecar.
|
||||
- **INV-3:** Reading a collection never hard-fails on bad metadata — an invalid value surfaces as a warning, the entry still loads, and the catalog flags it (§5.1).
|
||||
- **INV-4:** Metadata writes are authorized by scope-role (contributor+ on the collection) and validated at the write boundary; content-body edits keep their existing PR-review path.
|
||||
- **INV-5:** A collection with no `fields:` block behaves exactly as today (free-form `tags` only). The §22.13 generated **default collection is `document`** with no fields → **N=1 deployments see zero change**.
|
||||
- **INV-6:** Dual-read: parser reads the sidecar if present, else legacy top-of-doc frontmatter, with identical resulting in-memory records.
|
||||
- **INV-7:** Unknown / forward-compat keys in a sidecar **ride along untouched** — never dropped on read or rewrite, never reported as malformed.
|
||||
- **INV-8:** **Engine unchanged** (§22.4a) — additive and read-mostly; never forks the content write path, the propose→branch→PR→graduate lifecycle, threads/flags/chat, or the storage model. Metadata edits reuse the existing `edit-meta` git write-through.
|
||||
|
||||
### 6.2 High-level architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Git[content repo]
|
||||
CY[.collection.yaml<br/>fields: schema]
|
||||
MD[slug.md<br/>prose body]
|
||||
SC[slug.meta.yaml<br/>values]
|
||||
end
|
||||
CY --> ING[ingest / parser<br/>lenient, type-agnostic]
|
||||
MD --> ING
|
||||
SC --> ING
|
||||
ING --> VAL[metadata_schema.validate<br/>advisory at read]
|
||||
VAL --> DB[(cached_rfcs<br/>values + facet counts + malformed)]
|
||||
DB --> API[API: schema · list+filter · facets · edit]
|
||||
API --> FILT[left-pane faceted filters]
|
||||
API --> PANEL[detail metadata panel]
|
||||
API --> BULK[bulk select bar]
|
||||
PANEL -->|validate + direct commit| SC
|
||||
BULK -->|validate + 1 commit| SC
|
||||
SC -.read from git.-> CONS[downstream consumers]
|
||||
```
|
||||
|
||||
- **ingest/parser** — reads `.collection.yaml` schema + sidecars (or legacy frontmatter), stays lenient/type-agnostic (INV-7); rebuilds `cached_rfcs`; never authoritative.
|
||||
- **`metadata_schema.validate(values, fields) → [problems]`** — the one place that knows a collection's required/forbidden fields and each field's shape (modeled on `registry.py`). Advisory at ingest (warn + malformed flag, INV-3); enforced at the write boundary (INV-4).
|
||||
- **API** — serves the schema, filtered lists with facet counts + malformed flag, and metadata edits; never writes metadata anywhere but the sidecar.
|
||||
|
||||
### 6.3 Data model & ownership
|
||||
|
||||
| Entity | Owned by | Key fields | System of record |
|
||||
| --- | --- | --- | --- |
|
||||
| Collection field schema | Collection Owner | `fields: {name → {type, values?, label}}` in `.collection.yaml` | git |
|
||||
| Entry metadata values | Contributor | sidecar `<slug>.meta.yaml`: lifecycle + schema fields + forward-compat keys (INV-7) | git (sidecar) |
|
||||
| Derived index | ingest | per-entry values + facet aggregations + `malformed` flag | `cached_rfcs` (SQLite, derived) |
|
||||
|
||||
**Field types (v1):** `enum` (single-select; controlled by required `values:`), `tags` (multi-value; free-form unless `values:` given), `text` (free string). **Future:** `ref` (a typed cross-entry link — basis for the deferred bdd `verifies`/coverage surface; §2, §9 Q4). Unknown types ignored with a warning.
|
||||
|
||||
**Sidecar example:**
|
||||
```yaml
|
||||
slug: 01-01-0001-view-today-s-key-performance-metrics-at-a-glance
|
||||
title: View today's key performance metrics at a glance
|
||||
state: active
|
||||
owners: [ben.stull]
|
||||
priority: P1
|
||||
tags: [dashboard, analytics]
|
||||
owner: hasan
|
||||
```
|
||||
|
||||
### 6.4 Interfaces & contracts
|
||||
|
||||
- **`GET …/collections/<c>`** — out: collection incl. `fields` schema.
|
||||
- **`GET …/collections/<c>/rfcs`** — in: filter params (`?priority=P0&tags=checkout&state=active`; OR within a field, AND across fields; `?malformed=true`) · out: entries with values + per-entry `malformed` + `facets: {field → {value → count}}`. Errors: 400 unknown field.
|
||||
- **`POST …/rfcs/<slug>/meta`** — in: `{field: value}` · effect: validate → write sidecar → direct commit → re-ingest. Errors: 403, 422.
|
||||
- **`POST …/collections/<c>/meta/bulk`** — in: `{slugs, op: set|add|remove, field, value}` · out: `{applied, rejected}` · effect: validate → write N sidecars → one commit → re-ingest. Errors: 403, 422.
|
||||
|
||||
### 6.5 Per–Product-Use-Case design
|
||||
|
||||
#### PUC-2 — Bulk tag/untag
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor U as Contributor
|
||||
participant C as Catalog UI
|
||||
participant A as API
|
||||
participant V as metadata_schema
|
||||
participant G as Git
|
||||
participant D as cached_rfcs
|
||||
U->>C: select rows, "Set priority P1"
|
||||
C->>A: POST /meta/bulk {slugs, set, priority, P1}
|
||||
A->>A: authz (scope-role)
|
||||
A->>V: validate values vs schema
|
||||
A->>G: write N sidecars, 1 commit
|
||||
A->>D: re-ingest affected entries
|
||||
A-->>C: {applied, rejected}
|
||||
C-->>U: rows show P1; toast on any rejected
|
||||
```
|
||||
|
||||
- **Implementation:** reuse the `edit-meta` git write-through, extended to target the sidecar and batch N files into one commit. Honors INV-1/INV-4/INV-8.
|
||||
|
||||
#### PUC-5 — Migration
|
||||
|
||||
- **Implementation:** a tool walks a collection; for each entry with legacy frontmatter it writes `<slug>.meta.yaml` and rewrites `<slug>.md` to the body only — one commit per collection, idempotent, preserving unknown keys (INV-7). Dual-read (INV-6) lets it run anytime; lazy migration converts stragglers on first metadata edit.
|
||||
|
||||
### 6.6 Non-functional requirements & cross-cutting concerns
|
||||
|
||||
- **Security & privacy:** edits gated by `auth.effective_scope_role`; no secrets in sidecars; git history records authorship.
|
||||
- **Performance & scale:** facet counts from the derived DB; responsive at ~1.2k entries with dozens of tag values.
|
||||
- **Availability & resilience:** bad metadata never blocks read (INV-3); failed re-ingest leaves git authoritative, recoverable by rebuild.
|
||||
- **Observability:** log each metadata commit; warn-log + count schema-validation failures on ingest.
|
||||
- **Accessibility:** facet groups and form controls keyboard-navigable; checkboxes labelled value + count.
|
||||
|
||||
### 6.7 Key decisions & alternatives considered
|
||||
|
||||
| Decision | Chosen | Alternatives | Why |
|
||||
| --- | --- | --- | --- |
|
||||
| Solution type | Build into rfc-app | Manual (shared spreadsheet) | Manual leaves corpus unfilterable, out of sync, no git-readable signal (§2) |
|
||||
| Release modeling | Metadata only; releases downstream | First-class release entity | Operator pulled ordering/ship-status out of rfc-app |
|
||||
| Tag system shape | One generic typed-field system | Releases first-class + simple tags; namespaced facets | Tags/priority/custom are all just fields |
|
||||
| Schema model (D9) | Pure collection-config | Type-driven hard-coded schemas (per-type-surfaces draft) | Flexible, data-driven |
|
||||
| Metadata storage | Sidecar per entry | Frontmatter; end-of-doc; index file; DB-only | Clean docs + git-visible + locality |
|
||||
| Left-pane filtering | Faceted groups with counts | Flat facet chips | Scales to ~1.2k-scenario, many-tag corpus |
|
||||
| Edit governance | Direct commit for authorized roles | PR per change | Bulk planning impractical via PR-per-toggle |
|
||||
| bdd coverage (D10) | Future per-type surface over a `ref` field | Build now; drop | Valuable but not v1; needs Q4 |
|
||||
|
||||
### 6.8 Testing strategy
|
||||
|
||||
Unit: schema parsing (all types, missing block); sidecar round-trip incl. unknown-key preservation (INV-7); dual-read equivalence (INV-6); validation; malformed-flag; facet aggregation; bulk op (single commit, partial-rejection). Two-tier local-Docker→PPE for API + git write-through. "Tested" = PUC acceptance scenarios pass + migration proven idempotent and reversible-on-read.
|
||||
|
||||
### 6.9 Failure modes, rollback & flags
|
||||
|
||||
- **Invalid value committed out-of-band** → ingest warns + loads with the value flagged malformed (INV-3).
|
||||
- **Re-ingest fails after commit** → git authoritative; full rebuild recovers.
|
||||
- **Migration rollback:** dual-read keeps an un-/partly-migrated corpus working; the migration commit is revertible.
|
||||
- **Feature flag:** inherently opt-in per collection (INV-5) — no global flag.
|
||||
|
||||
## 7. Delivery Plan
|
||||
|
||||
### 7.1 Approach / strategy
|
||||
|
||||
Amend the binding contract first, then build storage/compat, then schema, then read, then write. Each build slice is shippable and non-breaking.
|
||||
|
||||
**Execution convention.** Each slice is taken as **its own coding session** — `writing-plans → executing-plans → verify → ship/deploy → merge + version bump` — in dependency order, with the slice's implementation plan written **just-in-time** at the start of that session, not up front (later slices' plans depend on the code earlier slices land). `brainstorming` ran once to produce this spec and recurs only if a slice proves the spec wrong. A slice's **Definition of Done** (§7.2) is the signal to advance the `Next /goal:` cursor to the next slice. SLICE-0 is doc-only (no implementation plan).
|
||||
|
||||
### 7.2 Slicing plan
|
||||
|
||||
#### SLICE-0 — Amend `SPEC.md` §22.4a (contract) → unblocks the rest
|
||||
- **Depends on:** —
|
||||
- **Definition of done:** §22.4a reframed — item 1 (entry schema) is **collection-configured sidecar fields**, not type-driven frontmatter; item 3 (type surfaces) deferred to a future design (bdd coverage recorded); per-type-surfaces draft marked superseded; §20 changelog. *Doc-only; no code.*
|
||||
|
||||
#### SLICE-1 — Sidecar storage + dual-read + migration → completes PUC-5, PUC-6
|
||||
- **Depends on:** SLICE-0
|
||||
- **DoD:** parser reads sidecar-else-legacy (INV-6), preserves unknown keys (INV-7); migration tool idempotent; existing collections load byte-identically; malformed flag derived; tests green.
|
||||
|
||||
#### SLICE-2 — Collection field schema + central validation → completes PUC-4
|
||||
- **Depends on:** SLICE-1
|
||||
- **DoD:** `.collection.yaml fields:` parsed; `metadata_schema.validate` advisory at read / enforced at write; schema served via the collection API; no-`fields:` collections unchanged (INV-5).
|
||||
|
||||
#### SLICE-3 — Faceted left-pane filtering (read) → completes PUC-3
|
||||
- **Depends on:** SLICE-2
|
||||
- **DoD:** list endpoint returns facet counts + honors filter params (incl. `malformed`); left pane renders faceted groups with counts + tag-value search; filters compose.
|
||||
|
||||
#### SLICE-4 — Single-entry metadata edit → completes PUC-1
|
||||
- **Depends on:** SLICE-2
|
||||
- **DoD:** detail panel renders schema controls; `POST …/meta` validates, direct-commits, re-ingests; scope-role gated (INV-4); lazy-migrates a legacy entry on first edit.
|
||||
- **Carried from SLICE-1 (deferred there):** make the **write paths**
|
||||
sidecar-aware — every site that today does `entry.parse(<slug>.md)` and
|
||||
serializes back into the `.md` must read/write metadata via the sidecar so a
|
||||
migrated (body-only) entry doesn't crash or re-grow frontmatter. The known
|
||||
sites: graduation + claim + `_read_meta_entry` (`api_graduation.py`),
|
||||
`mark_entry_reviewed` (`bot.py`), body-edit / accept-change wrappers
|
||||
(`api_branches.py` `_wrap_body`/`_extract_body`), and the PR-replay wrappers
|
||||
(`api_prs.py`). Only once these are sidecar-aware should the **operator
|
||||
trigger** for `metadata.migrate_collection` (the Owner-gated migrate endpoint)
|
||||
ship.
|
||||
|
||||
#### SLICE-5 — Bulk tag/untag → completes PUC-2
|
||||
- **Depends on:** SLICE-3, SLICE-4
|
||||
- **DoD:** multi-select + bulk bar; `POST …/meta/bulk` applies set/add/remove as one commit; partial-rejection reported.
|
||||
|
||||
### 7.3 Rollout / launch plan
|
||||
|
||||
Pre-v1, single production: ship slices in order; each minor bump carries §20 changelog + upgrade steps. Opt-in per collection (INV-5): a deployment adopts it only by declaring a `fields:` block and (optionally) running the migration.
|
||||
|
||||
### 7.4 Risks & mitigations
|
||||
|
||||
| Risk | L/I | Mitigation |
|
||||
| --- | --- | --- |
|
||||
| Amending binding §22.4a destabilises a shipped contract | M/M | SLICE-0 doc-only, reviewed; dual-read keeps runtime non-breaking; supersede note preserves rationale |
|
||||
| Frontmatter→sidecar migration corrupts content | L/H | Dual-read; idempotent, revertible migration; body-byte-identity + unknown-key tests |
|
||||
| Doubling file count (sidecars) clutters corpus | M/L | Docs stay clean; sidecars small/co-located |
|
||||
| Direct-commit metadata edits bypass review | M/M | Scope-role gate (INV-4); content-body edits still PR'd; git audit trail |
|
||||
| Facet aggregation slow at scale | L/M | Compute from indexed derived DB; measure at ~1.2k entries |
|
||||
|
||||
## 8. Traceability matrix
|
||||
|
||||
| Pain | Business UC | Product UC | Slice | Tests |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| — (contract) | — | — | SLICE-0 | (doc review) |
|
||||
| PP-5 | BUC-3 | PUC-5, PUC-6 | SLICE-1 | `test_dual_read_equiv`, `test_migration_idempotent`, `test_unknown_keys_preserved` |
|
||||
| PP-7 | BUC-4 | PUC-4 | SLICE-2 | `test_schema_parse`, `test_validate` |
|
||||
| PP-2 | BUC-5, BUC-1 | PUC-3 | SLICE-3 | `test_facet_counts`, `test_filter_compose` |
|
||||
| PP-1, PP-3 | BUC-1, BUC-4 | PUC-1 | SLICE-4 | `test_single_meta_commit`, `test_authz` |
|
||||
| PP-4 | BUC-2 | PUC-2 | SLICE-5 | `test_bulk_one_commit`, `test_partial_reject` |
|
||||
| PP-6 | BUC-3 | (consumer reads git) | — | `test_sidecar_schema_stable` |
|
||||
|
||||
## 9. Open Questions & Decisions log
|
||||
|
||||
**Open**
|
||||
|
||||
| # | Question | Owner | Blocks |
|
||||
| --- | --- | --- | --- |
|
||||
| Q1 | Do downstream consumers read sidecars from git, via API, or both? (leaning git) | Ben | nothing v1 |
|
||||
| Q2 | Ship `multi-enum` (multi-select controlled) in v1 or later? | Ben | SLICE-2 scope |
|
||||
| Q3 | Exact §22.4a amendment wording + the future-surfaces home | Ben | SLICE-0 |
|
||||
| Q4 | bdd coverage: `ref` field grammar + a coverage view honoring §22's no-cross-collection-join rule as hyperlinks | Ben | future surface |
|
||||
|
||||
**Resolved**
|
||||
|
||||
| # | Decision | Resolution | Date |
|
||||
| --- | --- | --- | --- |
|
||||
| D1 | Release behaviors | Out of rfc-app; downstream | 2026-06-06 |
|
||||
| D2 | Tag system shape | Approach A — one generic typed-field system | 2026-06-06 |
|
||||
| D3 | Metadata grain | Per entry (corpus already one file per scenario) | 2026-06-06 |
|
||||
| D4 | Schema location | `.collection.yaml` `fields:` block | 2026-06-06 |
|
||||
| D5 | Value storage | Sidecar `<slug>.meta.yaml`; doc body pure prose | 2026-06-06 |
|
||||
| D6 | Left-pane filtering | Faceted groups with counts | 2026-06-06 |
|
||||
| D7 | Edit governance | Direct commit for authorized roles; bulk = 1 commit | 2026-06-06 |
|
||||
| D8 | Management scope | Deferred; edit `.collection.yaml` in git for v1 | 2026-06-06 |
|
||||
| D9 | Schema model | Pure collection-config; not type-driven | 2026-06-06 |
|
||||
| D10 | bdd coverage | Future per-type surface over a `ref` field; not v1 | 2026-06-06 |
|
||||
| D11 | per-type-surfaces draft | Superseded; §22.4a to be amended (SLICE-0) | 2026-06-06 |
|
||||
|
||||
## 10. Glossary & References
|
||||
|
||||
- **Sidecar** — `<slug>.meta.yaml`, the per-entry metadata file that is the source of truth; keeps the `.md` body pure prose.
|
||||
- **Field schema** — the `fields:` block in `.collection.yaml` declaring a collection's typed metadata fields.
|
||||
- **Facet** — a schema field surfaced as a left-pane filter group with per-value counts.
|
||||
- **Malformed metadata** — stored values that fail their collection's schema; flagged in the catalog, never a hard read failure (INV-3).
|
||||
- **Downstream consumer** — an external tool that reads corpus metadata from git; rfc-app does not model releases.
|
||||
- **References:** retired BDD Release Planner; superseded per-type-surfaces draft (`2026-06-06-per-type-surfaces.md`); §22 three-tier design; `SPEC.md` §7.1 (left-pane filter), §9.5 (edit-meta), §20 (versioning), §22.4a (per-type contract — to be amended), §22 Part B / S3 (scope-role).
|
||||
```
|
||||
@@ -0,0 +1,366 @@
|
||||
# Draft spec — §22.4a per-type surfaces (the last S6 item)
|
||||
|
||||
> # ⛔ SUPERSEDED (2026-06-06)
|
||||
>
|
||||
> This draft is **superseded by**
|
||||
> [`2026-06-06-configurable-collection-metadata.md`](./2026-06-06-configurable-collection-metadata.md),
|
||||
> which reframes §22.4a item 1 as **collection-configured** metadata in
|
||||
> **sidecars** (not type-driven frontmatter) and defers item 3's surfaces.
|
||||
> Harvested into the successor: the validation seam (A.1), the malformed-metadata
|
||||
> catalog flag (A.5), unknown-fields-ride-along (C.1), the engine-unchanged rule
|
||||
> (§0), and the N=1 `document` backcompat anchor (A.2). The **bdd coverage**
|
||||
> capability (`feature`/`verifies` → coverage view, Part B.2) is preserved there
|
||||
> as a *future* per-type surface over a generic `ref` field. The binding
|
||||
> `SPEC.md` §22.4a contract is to be amended by the successor's SLICE-0. Kept for
|
||||
> historical rationale; do not build from this document.
|
||||
|
||||
> **Status:** discovery/spec pass — *not yet sliced into a shipped release.*
|
||||
> Author session: 0083 (2026-06-06). This document is the spec pass the §22 S6
|
||||
> remainder called for: it specifies **§22.4a item 1** (the per-type entry
|
||||
> **frontmatter schema**) and **§22.4a item 3** (the per-type **surfaces**) for
|
||||
> the `specification` and `bdd` collection types, with BDD-style acceptance
|
||||
> scenarios and a delivery slicing, so a later coding session can build them
|
||||
> against a written contract rather than improvising.
|
||||
>
|
||||
> It is the sibling of
|
||||
> [`2026-06-05-three-tier-projects-collections.md`](./2026-06-05-three-tier-projects-collections.md)
|
||||
> (the three-tier model, S1–S6 core, shipped through v0.46.0) and refines, in
|
||||
> implementable detail, what `SPEC.md` §22.4a states at the contract level.
|
||||
> §22.4a **item 2** (the type-driven entry noun / terminology) shipped in the S6
|
||||
> core (v0.45.0) and is out of scope here.
|
||||
|
||||
## 0. Why this is its own pass
|
||||
|
||||
`SPEC.md` §22.4a says a collection's immutable `type` selects exactly three
|
||||
things: (1) the entry **frontmatter schema**, (2) the **terminology**, and (3)
|
||||
the **type-specific surfaces**. The S6 core shipped (2) and merged the contract
|
||||
into `SPEC.md`; it also shipped the type *plumbing* — `collections.type` is
|
||||
immutable, validated (`registry.VALID_TYPES = {document, specification, bdd}`),
|
||||
and drives the entry noun. What it did **not** ship is any *behavior* keyed on
|
||||
type beyond the noun: every type today parses the same §2 baseline frontmatter
|
||||
(`backend/app/entry.py` is type-agnostic and lenient about unknown keys) and
|
||||
renders the same §7 catalog with no type-specific surface.
|
||||
|
||||
Items 1 and 3 were deliberately deferred at v0.45.0 (CHANGELOG: "they want a
|
||||
discovery/spec pass first, lacking BDD scenarios in Part C"). The three-tier
|
||||
design doc's Part C scenarios are all about **roles** (C.1–C.3); there are no
|
||||
scenarios describing what a `specification` release-planning view *does* or what
|
||||
a `bdd` coverage view *shows*. This document supplies them.
|
||||
|
||||
The governing constraint from §22.4a, which every proposal below honors:
|
||||
|
||||
> Type does not change the **engine** — every type uses the same content repo
|
||||
> (§22.3), the same propose→branch→PR→discuss→graduate lifecycle (§§9–13), the
|
||||
> same threads, flags, and chat. … the engine itself treats every entry as
|
||||
> markdown + frontmatter regardless of type.
|
||||
|
||||
So a per-type surface is **additive and read-mostly**: it reads the (now
|
||||
type-aware) frontmatter and presents a derived view. It never forks the write
|
||||
path, the PR lifecycle, or the storage model.
|
||||
|
||||
---
|
||||
|
||||
# Part A — Item 1: the per-type frontmatter schema
|
||||
|
||||
## A.1 Where validation lives today, and where it should land
|
||||
|
||||
`entry.py:parse()` reads a fixed set of §2 baseline fields and is **lenient**:
|
||||
unknown keys are ignored, future fields ride along untouched (its own docstring
|
||||
says so). That leniency is the seam. The per-type schema is layered as a
|
||||
**validator**, not a parser rewrite:
|
||||
|
||||
- `entry.py` keeps parsing the union of all known fields into the `Entry`
|
||||
dataclass (add the new optional fields below; absent → `None`/default, exactly
|
||||
as `models`/`funder`/`unreviewed` already do). The parser stays type-agnostic.
|
||||
- A new **`entry_schema.py`** module exposes `validate(entry, collection_type)
|
||||
-> list[str]` returning human-readable problems (empty = valid). It is the one
|
||||
place that knows which fields a type **requires**, which it **forbids**, and
|
||||
the **enum/shape** of each.
|
||||
- Validation is **advisory at parse, enforced at the write boundary.** The
|
||||
propose/edit/PR-merge paths (§9.1, §22.4b) call `entry_schema.validate` and
|
||||
surface problems the way the propose modal already surfaces field errors. A
|
||||
malformed historical file still *parses* (we never hard-fail a read — a
|
||||
deployment's existing corpus must keep loading), but the catalog flags it
|
||||
(§A.4) and the next write must fix it.
|
||||
|
||||
This mirrors how visibility/initial_state are validated centrally in
|
||||
`registry.py` rather than at each call site.
|
||||
|
||||
## A.2 `document` — unchanged (the §2 baseline)
|
||||
|
||||
`document` is the baseline: the §2 fields exactly as today
|
||||
(`slug, title, state, id, repo, proposed_by, proposed_at, owners, arbiters,
|
||||
tags`, plus the §6.6/§6.7 `models`/`funder` and §22.4c `unreviewed`/`reviewed_*`).
|
||||
No new fields, no type-specific surface. The §22.13 generated default collection
|
||||
is `document`, so **N=1 deployments see zero change** — the load-bearing
|
||||
backcompat guarantee.
|
||||
|
||||
## A.3 `specification` — versioned-spec metadata
|
||||
|
||||
A `specification` entry is a versioned technical spec (the archetype is this
|
||||
framework's own `SPEC.md`). Frontmatter **adds** (all optional at parse,
|
||||
required/validated per A.1 at write):
|
||||
|
||||
| Field | Shape | Meaning | Required when |
|
||||
|---|---|---|---|
|
||||
| `spec_version` | semver string (`MAJOR.MINOR.PATCH`) | the entry's own version | `state = active` |
|
||||
| `lifecycle` | enum `draft \| active \| superseded` | spec lifecycle, **orthogonal to** the §2.4 entry `state` | always (defaults `draft`) |
|
||||
| `supersedes` | list of slugs (in this collection) | specs this one replaces | optional |
|
||||
|
||||
Notes / decisions:
|
||||
|
||||
- **`lifecycle` ≠ `state`.** The §2.4 `state` (super-draft/active/withdrawn) is
|
||||
the *engine's* workflow position; `lifecycle` is the *spec's* editorial status.
|
||||
An `active` (graduated) entry can be `lifecycle: draft` (published but not yet
|
||||
ratified) or `superseded`. Keeping them orthogonal avoids overloading the
|
||||
shared state machine (the §22.4a "engine unchanged" rule).
|
||||
- **`supersedes` is validated as in-collection slugs** (§22.14 §2: slugs are
|
||||
unique *per collection*). A `superseded` lifecycle with no inbound
|
||||
`supersedes` from a newer entry is a soft warning in the surface, not a write
|
||||
error (the replacement may land later).
|
||||
- `spec_version` uses the same semver vocabulary as the framework `VERSION`/§20
|
||||
so the release surface (A.5 / Part B) can sort and group.
|
||||
|
||||
## A.4 `bdd` — feature/scenario metadata
|
||||
|
||||
A `bdd` entry states a feature as Given/When/Then scenarios. Frontmatter
|
||||
**adds**:
|
||||
|
||||
| Field | Shape | Meaning | Required when |
|
||||
|---|---|---|---|
|
||||
| `feature` | string | the feature's one-line statement (the "In order to / As a / I want" intent) | `state = active` |
|
||||
| `verifies` | list of refs | the `specification` entries/sections this feature exercises | optional |
|
||||
| `scenarios` | derived, **not** frontmatter | count/list parsed from the body's `Scenario:` blocks | n/a |
|
||||
|
||||
Notes / decisions:
|
||||
|
||||
- **`verifies` is a cross-collection ref.** A ref is `"<collection>/<slug>"` or
|
||||
`"<collection>/<slug>#<anchor>"`. The default `<collection>` is a sibling
|
||||
`specification` collection in the same project; an unqualified `<slug>` means
|
||||
"a spec slug in this project's specification collection" (resolved at render).
|
||||
This is the one place a `bdd` surface reaches across collections — and §22's
|
||||
"no app surface joins across collections" rule (`SPEC.md` line 5016) is
|
||||
**honored**: `verifies` is a *declared link rendered as a hyperlink*, not a
|
||||
query that fuses two corpora. The coverage view (B.2) aggregates these links
|
||||
but each entry still lives in exactly one collection.
|
||||
- **Scenarios are parsed from the body, not frontmatter.** Gherkin-style
|
||||
`Scenario:` / `Given`/`When`/`Then` lines in the markdown body are the source
|
||||
of truth; the surface counts and lists them. This keeps the authoring
|
||||
experience plain-markdown (the engine's invariant) — no structured
|
||||
scenario-editor write path.
|
||||
- `bdd` collections default `initial_state: active` (§22.4b) so a feature lands
|
||||
active-but-`unreviewed`; the schema validator therefore requires `feature` for
|
||||
active entries, which is every freshly-landed `bdd` entry.
|
||||
|
||||
## A.5 Schema surfacing in the existing chrome
|
||||
|
||||
Item-1 work is mostly invisible plumbing, but two small surfaces make it real
|
||||
without waiting for Part B:
|
||||
|
||||
1. **Propose/edit validation** — the propose modal and edit-branch flow run
|
||||
`entry_schema.validate` for the collection's type and block submit on errors
|
||||
(e.g. proposing into a `specification` collection without a `lifecycle`).
|
||||
2. **A "malformed frontmatter" catalog flag** — the §7 catalog marks entries
|
||||
whose stored frontmatter fails its type's schema (parallel to the §22.4c
|
||||
`unreviewed` filter), so a corpus migrated from `document`→… or hand-edited
|
||||
is visibly fixable.
|
||||
|
||||
---
|
||||
|
||||
# Part B — Item 3: the type-specific surfaces
|
||||
|
||||
A surface is an **additional view** layered on the shared §7 catalog +
|
||||
§8 entry view, selected on `collection.type`. It is read-derived from
|
||||
frontmatter + body; it adds no write path the engine doesn't already have.
|
||||
|
||||
## B.1 `specification` → the release-planning surface
|
||||
|
||||
§22.4a: "group entries/changes into versioned releases with a changelog +
|
||||
§20-style upgrade-steps per release." Concretely, a per-collection
|
||||
**Releases** view at `/p/<project>/c/<collection>/releases`:
|
||||
|
||||
- **A release** is a named, ordered version (e.g. `0.46.0`) with: the set of
|
||||
spec entries at a given `spec_version`/`lifecycle`, a changelog body, and an
|
||||
optional upgrade-steps block (the §20.4 RFC-2119 convention reused verbatim).
|
||||
- **Source of truth = the content repo**, per §22.2/§22.3. A release is a file
|
||||
in the collection's subfolder (proposal: `releases/<version>.md`,
|
||||
frontmatter `version` + `released_at` + `entries: [slug@spec_version, …]`,
|
||||
body = changelog + upgrade-steps). The registry mirror caches a `releases`
|
||||
table the way it caches `collections` — git is truth, the table is a cache
|
||||
(§22.2 "never written except by the mirror").
|
||||
- **The view** lists releases newest-first; each expands to its changelog +
|
||||
upgrade-steps and the entries it cut. An Owner (scope-role, §22.6) can cut a
|
||||
new release (a bot-committed file, exactly like create-collection commits a
|
||||
manifest — §22 S5 pattern); contributors read.
|
||||
- **Reuse, don't reinvent:** the changelog + upgrade-steps renderer is the same
|
||||
markdown the framework's own `CHANGELOG.md`/§20.4 uses; the "cut a release"
|
||||
write is the §22 S5 bot-commit-then-mirror pattern.
|
||||
|
||||
Deliberately **out of this surface** (deferred): cross-release diffing, automated
|
||||
version bumping, dependency graphs between specs. The MVP is "see the releases,
|
||||
their changelog, their upgrade-steps, and what each contained."
|
||||
|
||||
## B.2 `bdd` → the scenario/acceptance + coverage surfaces
|
||||
|
||||
§22.4a: "a scenario/acceptance view and a coverage view mapping features to the
|
||||
spec sections they exercise." Two read-derived views:
|
||||
|
||||
1. **Scenario/acceptance view** (per entry, on the §8 entry page): renders the
|
||||
body's parsed `Scenario:` blocks as a structured checklist — each scenario's
|
||||
Given/When/Then, plus the entry's `feature` line as the header. No new
|
||||
storage; pure body parse. This is the `bdd` analogue of the `document`
|
||||
entry's prose view.
|
||||
2. **Coverage view** (per collection, at
|
||||
`/p/<project>/c/<collection>/coverage`): a matrix of **features → the spec
|
||||
entries/sections they `verifies`**. Rows are this collection's `bdd` entries;
|
||||
columns (or grouped rows) are the referenced `specification` entries. Cells
|
||||
show "covered / declared-but-spec-missing / spec-section-with-no-feature".
|
||||
The view aggregates the `verifies` links (A.4) across the collection but
|
||||
renders each as a hyperlink into the spec collection — it does not fuse the
|
||||
corpora (the §22 cross-collection rule, B/A.4).
|
||||
|
||||
Deliberately **out of this surface** (deferred): executing scenarios, CI/test
|
||||
result ingestion, auto-detecting coverage from code. The MVP maps *declared*
|
||||
coverage (`verifies`), surfacing gaps for humans to close.
|
||||
|
||||
## B.3 How a surface is selected and routed
|
||||
|
||||
- The collection payload already carries `type` and `entry_noun`
|
||||
(`GET /api/projects/:id/collections/:cid`). The frontend's `ProjectLayout` /
|
||||
collection chrome reads `type` and mounts the type's surface routes
|
||||
(`releases` for `specification`; `coverage` for `bdd`) alongside the shared
|
||||
catalog. `document` mounts none.
|
||||
- Backend: a per-type router group (`api_releases.py`, `api_coverage.py`)
|
||||
guarded by the same §22.5 read gates as the rest of the collection; the
|
||||
release-cut write reuses `auth.is_collection_superuser` / the S5 bot pattern.
|
||||
- The "per-type module the framework selects on `collection.type`" (§22.4a) is
|
||||
realized as: backend `entry_schema.py` (item 1) + the two router groups
|
||||
(item 3), and frontend a `typeModules[type]` map of `{ schema, surfaces }`.
|
||||
Adding a future type = a new map entry + enum value, no rebuild (§22.4a "open
|
||||
set").
|
||||
|
||||
---
|
||||
|
||||
# Part C — Behavioral scenarios (BDD)
|
||||
|
||||
> Tagged for the proposed slices in Part D (`@S7a` = item 1 schemas; `@S7b` =
|
||||
> specification releases; `@S7c` = bdd surfaces). These are the acceptance gate
|
||||
> the implementing session writes tests against, in the Part C style of the
|
||||
> three-tier doc.
|
||||
|
||||
## C.1 Per-type frontmatter schema (`@S7a`)
|
||||
|
||||
```gherkin
|
||||
Scenario: document collection is unchanged
|
||||
Given a "document" collection
|
||||
When a contributor proposes an entry with the §2 baseline frontmatter only
|
||||
Then the proposal is accepted with no schema error
|
||||
|
||||
Scenario: specification entry requires a lifecycle
|
||||
Given a "specification" collection
|
||||
When a contributor proposes an entry with no `lifecycle`
|
||||
Then it defaults to lifecycle "draft" and is accepted
|
||||
And when an Owner graduates it to active without a `spec_version`
|
||||
Then the write is blocked with "spec_version is required for an active specification"
|
||||
|
||||
Scenario: bdd entry requires a feature statement once active
|
||||
Given a "bdd" collection whose initial_state is "active"
|
||||
When a contributor proposes an entry with no `feature`
|
||||
Then the write is blocked with "feature is required for a bdd entry"
|
||||
|
||||
Scenario: unknown future field still rides along
|
||||
Given any collection
|
||||
When an entry carries a frontmatter key no schema names
|
||||
Then it parses unchanged and is not reported as malformed
|
||||
|
||||
Scenario: malformed existing entry loads but is flagged
|
||||
Given a stored specification entry missing a required field
|
||||
When the catalog renders
|
||||
Then the entry still loads (read never hard-fails)
|
||||
And the catalog marks it "malformed frontmatter"
|
||||
```
|
||||
|
||||
## C.2 specification release-planning surface (`@S7b`)
|
||||
|
||||
```gherkin
|
||||
Scenario: an Owner cuts a release
|
||||
Given a "specification" collection with two active entries
|
||||
When the collection Owner cuts release "1.0.0" with a changelog and upgrade-steps
|
||||
Then a releases/1.0.0.md file is committed to the content repo
|
||||
And the registry mirror caches the release
|
||||
And the Releases view lists "1.0.0" newest-first with its changelog + upgrade-steps
|
||||
|
||||
Scenario: a contributor reads releases but cannot cut one
|
||||
Given a contributor (not Owner) in the collection
|
||||
Then the Releases view is read-only (no "Cut release" control)
|
||||
|
||||
Scenario: upgrade-steps render with the §20.4 convention
|
||||
Given a release whose body uses MUST/SHOULD/MAY upgrade-steps
|
||||
Then they render with the same normative-language styling as CHANGELOG.md
|
||||
```
|
||||
|
||||
## C.3 bdd scenario + coverage surfaces (`@S7c`)
|
||||
|
||||
```gherkin
|
||||
Scenario: an entry's scenarios render as an acceptance checklist
|
||||
Given a "bdd" entry whose body has two Scenario: blocks
|
||||
When the entry page renders
|
||||
Then it shows the `feature` header and both scenarios' Given/When/Then
|
||||
|
||||
Scenario: coverage maps features to the specs they verify
|
||||
Given a "bdd" entry that `verifies: ["spec/auth#sessions"]`
|
||||
And a sibling "specification" collection "spec" containing entry "auth"
|
||||
When the coverage view renders
|
||||
Then a row links the feature to spec/auth#sessions as covered
|
||||
|
||||
Scenario: a declared ref to a missing spec is surfaced as a gap
|
||||
Given a "bdd" entry that `verifies: ["spec/ghost"]` where no such spec exists
|
||||
Then the coverage view marks that ref "declared but spec missing"
|
||||
|
||||
Scenario: coverage does not fuse corpora
|
||||
Then each cell is a hyperlink into the spec collection
|
||||
And no entry from the spec collection is listed as if it belonged to the bdd collection
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Part D — Delivery slicing
|
||||
|
||||
Each slice lands a usable increment + a runnable acceptance gate (`--tags @S7x`),
|
||||
in the three-tier doc's slicing style. Suggested order (item 1 first — the
|
||||
surfaces read its fields):
|
||||
|
||||
- **S7a — per-type frontmatter schema (item 1).** `entry_schema.py` +
|
||||
the new optional `Entry` fields + write-boundary validation + the catalog
|
||||
"malformed" flag. **Usable:** proposing into a typed collection is validated;
|
||||
N=1 `document` unchanged. **Completes:** `@S7a` (C.1). *Non-breaking, additive.*
|
||||
- **S7b — specification release planning (item 3a).** `releases/<v>.md` storage
|
||||
+ registry mirror + `api_releases.py` + the Releases view + cut-release write.
|
||||
**Usable:** a spec collection has versioned releases with changelog +
|
||||
upgrade-steps. **Completes:** `@S7b` (C.2).
|
||||
- **S7c — bdd scenario + coverage surfaces (item 3b).** Body scenario parser +
|
||||
the per-entry acceptance view + the per-collection coverage view +
|
||||
`api_coverage.py`. **Usable:** a bdd collection shows scenarios and declared
|
||||
coverage. **Completes:** `@S7c` (C.3).
|
||||
|
||||
All three are **additive** (new optional frontmatter, new tables that are pure
|
||||
caches, new read views): each is a minor, non-breaking release, and a `document`
|
||||
N=1 deployment is unaffected by any of them. None touches the engine, the PR
|
||||
lifecycle, or the role model — they consume the §22 three-tier + §22.6 role
|
||||
work already shipped.
|
||||
|
||||
## D.1 Open questions for the implementing session
|
||||
|
||||
1. **Release identity vs. entry `spec_version`.** Should a release's `entries`
|
||||
pin exact `slug@spec_version` (immutable snapshot) or just slugs (live)? This
|
||||
doc proposes the pinned snapshot; confirm against a real spec-collection
|
||||
workflow before building S7b.
|
||||
2. **`verifies` ref grammar.** `"<collection>/<slug>#<anchor>"` is proposed;
|
||||
anchor resolution into a spec entry's section needs the spec body to carry
|
||||
stable anchors. May want a lightweight `## §n` anchor convention on
|
||||
`specification` entries first.
|
||||
3. **Whether `lifecycle` belongs in the shared state machine after all.** Kept
|
||||
orthogonal here; revisit if product wants `superseded` to gate the catalog.
|
||||
|
||||
These are genuine product decisions a discovery/spec session (or the operator)
|
||||
should settle before S7b/S7c code; S7a (schemas) is unblocked and buildable now.
|
||||
@@ -0,0 +1,121 @@
|
||||
# Deployed-environment E2E harness (PPE)
|
||||
|
||||
**Date:** 2026-06-07 · **Version:** v0.52.0 · **Status:** implemented
|
||||
|
||||
## Why
|
||||
|
||||
The §9 deployment pipeline is `localhost + E2E → PPE + E2E → prod`. The
|
||||
middle stage — running the Playwright E2E suite against a *deployed*
|
||||
pre-prod host (`https://rfc-ppe.wiggleverse.org`) — was unreachable
|
||||
because the suite (`e2e/metadata.spec.js`, SLICE-3/4/5 of the
|
||||
configurable-collection-metadata work) was bound to three scaffolds that
|
||||
exist only in the local Tier-1 docker stack:
|
||||
|
||||
1. **A faceted `bdd` collection**, seeded into a throwaway Gitea by
|
||||
`testing/seed-gitea.sh`.
|
||||
2. **A granted-owner identity** (`e2e-owner@example.test`), injected
|
||||
directly into SQLite by the docker-compose `backend-seed` step.
|
||||
3. **Mailpit**, the SMTP sink the OTC sign-in reads the one-time code
|
||||
from.
|
||||
|
||||
PPE has none of these: it runs against the real `git.wiggleverse.org`
|
||||
(shared with prod), has no direct DB access, and has no mail sink. This
|
||||
note records how each coupling is replaced so the *same* spec runs green
|
||||
against both localhost and PPE.
|
||||
|
||||
## The three seams
|
||||
|
||||
### 1. Auth — a gated test-login endpoint (the framework change)
|
||||
|
||||
A new backend route, `POST /auth/test/login`, replaces both the Mailpit
|
||||
OTC dance *and* the SQLite owner injection with one gesture: it mints an
|
||||
authenticated **owner** session for a single pre-configured identity.
|
||||
|
||||
It is the framework's only auth bypass, so it is **fail-closed** and must
|
||||
never function in production:
|
||||
|
||||
- **Off by default.** It returns `404` unless **both**
|
||||
`E2E_TEST_AUTH_SECRET` and `E2E_TEST_AUTH_EMAIL` are set. A production
|
||||
deployment sets neither, so the route is invisible and inert.
|
||||
- **Secret-gated.** The caller must present `E2E_TEST_AUTH_SECRET` in the
|
||||
`X-Test-Auth-Secret` header, compared in constant time. A wrong/absent
|
||||
secret returns `404` (it does not advertise the route's existence).
|
||||
- **Single identity.** It will only mint the one configured
|
||||
`E2E_TEST_AUTH_EMAIL` (case-insensitive); any other address is `403`.
|
||||
So an enabled PPE exposes exactly one throwaway owner, with the secret
|
||||
as the trust boundary.
|
||||
- **Loud at startup.** When enabled, the app logs a `WARNING` at boot, so
|
||||
an accidental prod enablement is visible rather than silent.
|
||||
|
||||
On success it provision-or-links the row (reusing `otc.provision_or_link_user`),
|
||||
forces it to `role='owner', permission_state='granted'` (the deployed
|
||||
equivalent of the Tier-1 owner-seed), and stores the session exactly like
|
||||
the OTC verify path.
|
||||
|
||||
**Why an endpoint rather than alternatives.** Reading the OTC code from
|
||||
the VM's journald (the email adapter logs the envelope to stdout when
|
||||
SMTP is unconfigured) would couple the test harness to `gcloud` SSH at
|
||||
runtime — slow, brittle, and operator-cred-bound. Running Mailpit on the
|
||||
VM and exposing its API publicly is more infra and its own exposure
|
||||
surface. A default-off, secret-gated endpoint is the portable engineering
|
||||
seam: it works for *any* deployed environment, needs no SSH, and the
|
||||
secrets rule (§6.3) is honored — the secret is a Secret Manager ref
|
||||
injected as VM env, never a literal.
|
||||
|
||||
The hard-secrets caveat: the E2E runner presents the secret by resolving
|
||||
it from Secret Manager at runtime (command substitution), never echoing
|
||||
it.
|
||||
|
||||
### 2. Content — a dedicated PPE registry + content repo
|
||||
|
||||
PPE shares the prod Gitea org (`wiggleverse`) and, until now, prod's
|
||||
registry (`rfc-registry`) and default project (`ohm`). Seeding a faceted
|
||||
test collection into that shared registry would surface it on **prod**.
|
||||
|
||||
So PPE gets its **own**, prod-untouching fixtures:
|
||||
|
||||
- `wiggleverse/rfc-registry-ppe` — PPE's project registry. Prod keeps
|
||||
`rfc-registry`, so prod is never affected.
|
||||
- `wiggleverse/rfc-app-ppe-content` — one project `ohm` (document) with a
|
||||
default collection entry plus a faceted `bdd` named collection
|
||||
(`priority` enum + `tags`) and three entries, mirroring the Tier-1
|
||||
seed. The E2E path `/p/ohm/c/bdd` therefore resolves identically on
|
||||
both environments.
|
||||
|
||||
PPE is pointed at it with `overlay set rfc-app-ppe
|
||||
REGISTRY_REPO=rfc-registry-ppe`. The startup reconciler sweep loads the
|
||||
content into `cached_rfcs` (incl. `meta_json` for facets) on the next
|
||||
deploy — no webhook needed for the initial load. The seed is scripted in
|
||||
`testing/seed-ppe.sh` (idempotent; `RESEED=1` restores entry values for a
|
||||
re-run). Repo *creation* is a one-time operator gesture (the
|
||||
`write:repository` Keychain token cannot create org repos; create the two
|
||||
empty repos in the Gitea UI or re-scope the PAT).
|
||||
|
||||
### 3. Parameterization — one spec, two environments
|
||||
|
||||
- `e2e/playwright.config.js` already honors `BASE_URL`
|
||||
(default `http://localhost:8080`); PPE sets
|
||||
`BASE_URL=https://rfc-ppe.wiggleverse.org`.
|
||||
- `e2e/lib/auth.js` branches on `E2E_TEST_AUTH_SECRET`: set → use
|
||||
`/auth/test/login`; unset → the original Mailpit OTC path. `OWNER_EMAIL`
|
||||
reads `E2E_OWNER_EMAIL` (PPE points it at `E2E_TEST_AUTH_EMAIL`) or the
|
||||
Tier-1 default. The spec itself is unchanged, so the localhost Tier-1
|
||||
path keeps working.
|
||||
|
||||
## PPE version
|
||||
|
||||
The harness *requires* the test-login endpoint to exist in the deployed
|
||||
build, so PPE must run a framework version that contains it — **v0.52.0**,
|
||||
not v0.51.1. PPE is pinned ahead of prod via its own
|
||||
`ben/ohm-rfc/.rfc-app-version.ppe` (prod stays on `.rfc-app-version`),
|
||||
realizing the "PPE stages newer versions first" note the
|
||||
`deployment.ppe.toml` always anticipated.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **Re-runnability.** SLICE-4/5 mutate the seeded entries (commit
|
||||
sidecars). A clean run needs seed-state preconditions; re-run after
|
||||
`RESEED=1` + a cache refresh (next reconciler sweep or a redeploy).
|
||||
Unlike Tier-1's `make e2e-fresh`, PPE has no per-run teardown.
|
||||
- **smoke.spec.js** stays Tier-1-only (anonymous OTC smoke through
|
||||
Mailpit); only `metadata.spec.js` runs against PPE.
|
||||
@@ -0,0 +1,108 @@
|
||||
# SLICE-1 plan — sidecar storage + dual-read + migration + malformed flag
|
||||
|
||||
Just-in-time implementation plan for **SLICE-1** of
|
||||
[Configurable Collection Metadata](../2026-06-06-configurable-collection-metadata.md)
|
||||
(§7.2). Authored at the start of the SLICE-1 coding session (session 0084,
|
||||
2026-06-07), against the code SLICE-0 (v0.46.2) landed.
|
||||
|
||||
## Scope (and non-scope)
|
||||
|
||||
**In:** the storage/compat layer only — sidecar files become the source of
|
||||
truth for entry metadata, with a dual-read parser, an idempotent
|
||||
frontmatter→sidecar migration tool, and a derived `metadata_malformed` flag.
|
||||
|
||||
**Out (later slices):** the `.collection.yaml` `fields:` schema + validation
|
||||
(SLICE-2), faceted filtering (SLICE-3), the edit/bulk UIs (SLICE-4/5). SLICE-1
|
||||
maps sidecar values onto the **existing** typed `cached_rfcs` columns; it does
|
||||
not add per-field schema columns or facet aggregation.
|
||||
|
||||
## Invariants honored
|
||||
|
||||
- **INV-6 dual-read:** parser reads the sidecar if present, else legacy
|
||||
top-of-doc frontmatter, with identical resulting in-memory records.
|
||||
- **INV-7 unknown keys ride along:** preserved through parse→serialize and
|
||||
through the migration (never dropped).
|
||||
- **INV-2:** a migrated `.md` body contains no metadata.
|
||||
- **INV-1:** `cached_rfcs` stays a derived, rebuildable index; the sidecar in
|
||||
git is the source of truth.
|
||||
- **INV-3:** bad metadata never hard-fails a read — the entry still loads and
|
||||
the catalog flags it (`metadata_malformed`).
|
||||
- **INV-5 / byte-identity:** a collection with no sidecars behaves exactly as
|
||||
today (legacy frontmatter path); existing entries load identically.
|
||||
|
||||
## Components
|
||||
|
||||
1. **`app/entry.py` — unknown-key preservation (INV-7).** Add
|
||||
`extra: dict[str, Any]` to `Entry`. `parse()` collects frontmatter keys
|
||||
outside the known set into `extra`; `serialize()` re-emits them after the
|
||||
known keys. Makes frontmatter round-trips lossless.
|
||||
|
||||
2. **`app/metadata.py` — new module (sidecar concerns).**
|
||||
- `SIDECAR_SUFFIX = ".meta.yaml"`; `sidecar_name(slug)`,
|
||||
`is_sidecar(name)`, `slug_of_sidecar(name)`.
|
||||
- `metadata_dict(entry) -> dict` — the full metadata mapping (known
|
||||
emit-rules + `extra`), shared by the sidecar writer and the frontmatter
|
||||
serializer.
|
||||
- `sidecar_yaml(entry) -> str` — canonical YAML for a sidecar from
|
||||
`metadata_dict`.
|
||||
- `strip_frontmatter(md_text) -> str` — body-only (drops a leading
|
||||
`---…---` block if present; whole text otherwise).
|
||||
- `parse_sidecar(text) -> tuple[dict, bool]` — lenient: `(values, malformed)`;
|
||||
non-mapping / YAML error → `({}, True)`.
|
||||
- `read_entry(md_text, sidecar_text|None) -> tuple[Entry, bool]` — dual-read:
|
||||
sidecar present → metadata from sidecar values, body from
|
||||
`strip_frontmatter(md_text)`, `malformed` from `parse_sidecar`; absent →
|
||||
`entry.parse(md_text)`, `malformed=False`.
|
||||
|
||||
3. **`app/gitea.py` — `change_files(...)` batch commit.** `POST
|
||||
/repos/{owner}/{repo}/contents` (Gitea ChangeFiles) with a `files[]` array
|
||||
of `{operation, path, content(b64), sha?}` — one commit for N files. Backs
|
||||
the migration's "one commit per collection".
|
||||
|
||||
4. **`metadata.migrate_collection(gitea, org, repo, subfolder, actor)`.**
|
||||
Lists `<subfolder>/rfcs`; for each `<slug>.md` **without** a `<slug>.meta.yaml`
|
||||
sibling and **with** legacy frontmatter, batch: create the sidecar
|
||||
(`metadata_dict` → YAML) + update the `.md` to body-only. One ChangeFiles
|
||||
commit per collection. Idempotent (skip entries already migrated; no-op when
|
||||
none remain). Returns a summary (`migrated`, `skipped`, `committed`).
|
||||
|
||||
> **Deferred (decided mid-slice, after code review):** the **operator
|
||||
> trigger** for this tool (an Owner-gated endpoint) is held back to SLICE-4.
|
||||
> The propose/graduate/mark-reviewed/edit write paths still `entry.parse` the
|
||||
> `.md` directly, so migrating a corpus to body-only `.md`s before those
|
||||
> paths are sidecar-aware would break them (crash / re-introduce
|
||||
> frontmatter). SLICE-1 ships the tool as tested groundwork; SLICE-4 makes
|
||||
> the write paths sidecar-aware (and adds lazy migration) and is where the
|
||||
> trigger belongs (INV-8: engine write paths unchanged this slice).
|
||||
|
||||
5. **`app/cache.py` — dual-read in `_refresh_collection_corpus`.** Build a
|
||||
sidecar-by-stem map from the dir listing; for each `.md`, read its sidecar
|
||||
sibling (if any), `metadata.read_entry(...)`, thread `malformed` into
|
||||
`_upsert_cached_rfc(metadata_malformed=…)`.
|
||||
|
||||
6. **`backend/migrations/033_metadata_malformed.sql`** — additive
|
||||
`ALTER TABLE cached_rfcs ADD COLUMN metadata_malformed INTEGER NOT NULL DEFAULT 0`.
|
||||
|
||||
7. **`app/api.py` — surface the flag.** Add `metadata_malformed` (bool) to the
|
||||
two catalog list dicts and `get_rfc`/`_get_rfc_for_collection`. (Frontend
|
||||
badge + `?malformed=` filter are SLICE-3.)
|
||||
|
||||
## Tests (TDD — write first)
|
||||
|
||||
- `test_metadata.py` (unit, pure): dual-read equivalence (sidecar vs legacy →
|
||||
identical Entry); unknown-key preservation through parse→serialize and
|
||||
through `metadata_dict`; `strip_frontmatter` (with/without frontmatter);
|
||||
`parse_sidecar` malformed cases.
|
||||
- `test_metadata_migration.py` (integration, FakeGitea): migrate a collection
|
||||
→ sidecars written + `.md` bodies stripped + one commit; **idempotent**
|
||||
(second run is a no-op); unknown keys preserved in the sidecar.
|
||||
- extend the cache/propose vertical: a collection with a sidecar mirrors from
|
||||
the sidecar; a malformed sidecar sets `metadata_malformed` and still loads
|
||||
the entry (INV-3); a no-sidecar collection is byte-identical to today.
|
||||
|
||||
## Release
|
||||
|
||||
Minor bump **0.46.2 → 0.47.0** (new functionality: sidecar storage + migration
|
||||
tool; non-breaking — additive migration 033, dual-read keeps legacy corpora
|
||||
working, opt-in). §20 CHANGELOG + upgrade-steps: migration 033 auto-applies;
|
||||
running the migration tool per collection is optional (**MAY**).
|
||||
@@ -0,0 +1,109 @@
|
||||
# Implementation plan — SLICE-2: collection field schema + central validation
|
||||
|
||||
**Slice:** SLICE-2 of
|
||||
[`docs/design/2026-06-06-configurable-collection-metadata.md`](../2026-06-06-configurable-collection-metadata.md)
|
||||
§7.2. **Session:** OHM-0085. **Branch:** `worktree-metadata-slice2-schema`.
|
||||
|
||||
## Goal / Definition of Done (from the design)
|
||||
|
||||
- `.collection.yaml` `fields:` block is parsed and stored.
|
||||
- `metadata_schema.validate` exists — **advisory at read**, the enforcement
|
||||
point **at write** (write endpoints land in SLICE-4/5; this slice supplies and
|
||||
read-wires the function).
|
||||
- The schema is served via the collection API (`GET …/collections/{id}`).
|
||||
- A collection with **no `fields:`** behaves exactly as today (INV-5) — the
|
||||
§22.13 default `document` collection sees zero change.
|
||||
|
||||
## Design decisions (this slice)
|
||||
|
||||
- **Field-def shape** (design §6.3): `fields:` is an ordered mapping
|
||||
`{name → {type, values?, label?}}`. `type ∈ {enum, tags, text}` (v1).
|
||||
Order is preserved for facet display (SLICE-3).
|
||||
- `enum` — single scalar; requires a non-empty `values:` list.
|
||||
- `tags` — list; `values:` optional (controlled when present, free-form
|
||||
otherwise).
|
||||
- `text` — free string scalar.
|
||||
- **`ref` and `multi-enum` are out of v1** (design §2 future; Q2 leans "later").
|
||||
Unknown field types are **ignored with a warning** (design §6.3), not fatal.
|
||||
- **Lenient schema parsing.** A malformed `fields:` block or an individual bad
|
||||
field def is skipped with a warning, never raised — a typo in one field must
|
||||
not nuke the collection mirror (INV-3 spirit). Structural manifest errors
|
||||
(`type`, `visibility`) keep raising `RegistryError` as before.
|
||||
- **No DB migration.** The normalized `fields` schema rides in the existing
|
||||
`collections.config_json` column, exactly like `enabled_models`.
|
||||
- **`values` source for validation.** An entry's full metadata mapping
|
||||
(`metadata.metadata_dict(entry)` = `to_frontmatter_dict`) — `tags` come from
|
||||
the known `Entry.tags`; custom fields (e.g. `priority`) come from
|
||||
`Entry.extra`. Undeclared keys are forward-compat (INV-7) and never flagged.
|
||||
|
||||
## Tasks
|
||||
|
||||
### 1. `app/metadata_schema.py` (new) — the one place that knows field shapes
|
||||
- `VALID_FIELD_TYPES = {"enum", "tags", "text"}`.
|
||||
- `parse_fields(raw) -> dict[str, dict]` — lenient/normalizing. Returns an
|
||||
ordered mapping of `{name: {"type", "values"?, "label"?}}`. Skips: non-mapping
|
||||
block; non-mapping field def; unknown/missing type; `enum` without a non-empty
|
||||
`values:` list. Logs a warning per skip. Pure (no I/O).
|
||||
- `@dataclass Problem(field, code, message)` + `as_dict()`.
|
||||
- `validate(values: dict, fields: dict[str, dict]) -> list[Problem]` — for each
|
||||
**declared** field, validate the entry's value (absent is OK):
|
||||
- `enum`: scalar ∈ `values:` else `not-in-values`; a list/non-scalar →
|
||||
`wrong-type`.
|
||||
- `tags`: must be a list (`wrong-type` otherwise); if controlled, each member
|
||||
∈ `values:` else `not-in-values`.
|
||||
- `text`: must be a scalar string (`wrong-type` otherwise).
|
||||
Undeclared keys ignored (INV-7). Never raises.
|
||||
- **Tests** `tests/test_metadata_schema.py`: parse (each type, missing block,
|
||||
enum-without-values skipped, unknown type skipped, order preserved); validate
|
||||
(happy, enum bad value, tags uncontrolled-ok, tags controlled-bad, text wrong
|
||||
type, undeclared key ignored, empty schema → no problems).
|
||||
|
||||
### 2. `registry.py` — parse `fields:` into the collection config
|
||||
- In `parse_collection_manifest`, after the existing keys: if `raw.get("fields")`
|
||||
present, `cfg["fields"] = metadata_schema.parse_fields(raw["fields"])` (only
|
||||
set when non-empty). Flows into `CollectionEntry.config` →
|
||||
`config_json` via the existing `json.dumps(ce.config)` in
|
||||
`_upsert_named_collection`.
|
||||
- **Default collection** (`apply_registry`) currently writes the `collections`
|
||||
row **without** `config_json`. A default collection's `fields:` would live in
|
||||
`projects.yaml`? No — the design says `fields:` is a `.collection.yaml` block.
|
||||
The default collection has no `.collection.yaml`. **Decision:** default-
|
||||
collection field schemas are out of this slice's happy path (the N=1 default is
|
||||
`document` with no fields, INV-5). Leave `apply_registry` untouched; only
|
||||
named collections (with a `.collection.yaml`) carry `fields:`. Documented as a
|
||||
known limitation (a deployment wanting fields on its primary corpus declares a
|
||||
named collection — consistent with the design's opt-in story).
|
||||
- **Tests** extend `tests/test_collection_registry.py`: a manifest with a
|
||||
`fields:` block round-trips into `get_collection(...)["fields"]`; a manifest
|
||||
with a bad field def still upserts (lenient).
|
||||
|
||||
### 3. `collections.py` — unpack `fields` on read + serve via API
|
||||
- Add `_fields_from_config(config_json) -> dict | None` (mirror
|
||||
`_enabled_models_from_config`).
|
||||
- `get_collection` sets `out["fields"] = _fields_from_config(config_json)`
|
||||
(alongside `enabled_models`). `api_collections.get_col` then serves it with no
|
||||
change. `list_collections` left as-is (facets are SLICE-3).
|
||||
- **Tests** extend `tests/test_collection_helpers.py`: `get_collection` exposes
|
||||
`fields`; a no-fields collection → `fields is None` (INV-5).
|
||||
|
||||
### 4. `cache.py` — advisory validation at ingest (INV-3)
|
||||
- In `_refresh_collection_corpus`, fetch the collection's `fields` schema once
|
||||
(`collections.get_collection(collection_id)`); for each entry, if a schema is
|
||||
present, `problems = metadata_schema.validate(metadata.metadata_dict(entry),
|
||||
fields)` and OR any problems into `metadata_malformed` (warn-log a summary).
|
||||
No schema → behavior identical to today (INV-5).
|
||||
- **Tests** `tests/test_metadata_cache.py` (extend): an entry violating an enum
|
||||
field ingests with `metadata_malformed = 1`; a conforming entry → `0`; a
|
||||
collection with no schema → `0` regardless of extra keys.
|
||||
|
||||
### 5. Verify · version · ship
|
||||
- `pytest` full backend suite green (575 baseline + new).
|
||||
- Bump `VERSION` + `frontend/package.json` → **0.48.0**; CHANGELOG minor entry
|
||||
(§20) — non-breaking, opt-in, N=1 unchanged; note write-enforcement lands with
|
||||
SLICE-4/5.
|
||||
- Commit (cite design §7.2 SLICE-2), PR on Gitea `origin`, merge to `main`.
|
||||
|
||||
## Invariants honored
|
||||
INV-3 (read never hard-fails — advisory malformed), INV-5 (no-`fields:`
|
||||
unchanged), INV-7 (undeclared keys ride along, never flagged), INV-8 (additive,
|
||||
read-mostly; no write-path fork — write enforcement is a later slice).
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,802 @@
|
||||
# SLICE-5 — Bulk tag/untag Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Let an authorized user apply one metadata field change (set / add-tag / remove-tag) to many catalog entries at once, committed as a single git commit, with per-entry partial-rejection reported — completing PUC-2 of the [Configurable Collection Metadata](../../design/2026-06-06-configurable-collection-metadata.md) design (§6.4, §6.5, D7).
|
||||
|
||||
**Architecture:** A new backend endpoint `POST /api/projects/{pid}/collections/{cid}/meta/bulk` reuses the SLICE-4 sidecar-aware git helpers (`metadata.read_entry_from_git` / `apply_values` / `write_entry_files`) and the `bot.commit_entry_files` multi-file primitive: read each entry, apply the op, validate at the write boundary (INV-4), collect file ops for the passing entries, and commit them all in **one** `change_files` call (D7: bulk = 1 commit). Entries that fail validation or are missing are reported in `rejected`, others in `applied`. The frontend adds row multi-select + a sticky bulk action bar in `Catalog.jsx` (faceted, contributor+ only), driven by the collection `fields:` schema, calling a new `bulkEntryMeta` API helper and toasting partial rejections.
|
||||
|
||||
**Tech Stack:** Python / FastAPI / pytest (backend); React / Vitest (frontend); Gitea ChangeFiles for the one-commit write.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- `backend/app/api_metadata.py` — **modify**: add the `bulk_meta` route + a `BulkMetaBody` model alongside the existing single-edit and migrate routes. Add a small pure helper `_apply_op(entry, op, field, value)` (or inline) computing the new field value for set/add/remove.
|
||||
- `backend/tests/test_metadata_bulk_endpoint.py` — **create**: endpoint tests (one commit, partial reject, authz, op validation), reusing the `test_propose_vertical` fake-Gitea harness like `test_metadata_edit_endpoint.py`.
|
||||
- `frontend/src/api.js` — **modify**: add `bulkEntryMeta(projectId, collectionId, { slugs, op, field, value })`.
|
||||
- `frontend/src/components/BulkActionBar.jsx` — **create**: the sticky bar (N selected · Set <enum> ▾ · Add/Remove tag · Clear), driven by `fields`.
|
||||
- `frontend/src/components/BulkActionBar.test.jsx` — **create**: render + interaction unit tests.
|
||||
- `frontend/src/components/Catalog.jsx` — **modify**: per-row selection checkboxes (faceted + contributor only), selection state, render `BulkActionBar`, apply handler + toast, re-fetch on success.
|
||||
- `CHANGELOG.md`, `VERSION`, `frontend/package.json` — **modify**: v0.51.0 release entry + version bump.
|
||||
|
||||
---
|
||||
|
||||
## Backend op semantics (reference for all backend tasks)
|
||||
|
||||
Endpoint: `POST /api/projects/{pid}/collections/{cid}/meta/bulk`
|
||||
Body: `{ "slugs": ["a","b"], "op": "set"|"add"|"remove", "field": "priority", "value": <any> }`
|
||||
Response (200): `{ "ok": true, "applied": ["a"], "rejected": [{"slug":"b","reason":"..."}], "committed": true }`
|
||||
|
||||
Rules:
|
||||
- **Authz:** `auth.can_contribute_in_collection(viewer, collection_id)` — else 403 (anonymous → 403). Same gate as the single-entry edit.
|
||||
- **Collection-in-project:** mismatch → 404. No `fields:` → 422. Empty `slugs` → 422. `op` not in `{set,add,remove}` → 422. `field` not in schema → 422.
|
||||
- **`set`** valid for any field; new value = `value` as given.
|
||||
- **`add` / `remove`** valid only for `tags`-type fields (else 422); they read the entry's current list and append / drop the single `value`.
|
||||
- **Per entry:** read via `read_entry_from_git`; missing `.md` → reject `{slug, reason:"not found"}`. Apply op → `apply_values`. Validate `metadata_schema.validate(metadata_dict(new_entry), fields)`; problems → reject `{slug, reason:"<problem messages>"}`. If the resulting metadata equals the old, count as applied but emit **no** file op (avoid redundant writes). Otherwise collect `write_entry_files(md_path, new_entry, state)` ops.
|
||||
- **Commit:** concatenate all passing entries' file ops into one list; if non-empty, one `bot.commit_entry_files(..., message="Bulk <op> <field>: N entries")`; `committed=true`. If empty (all rejected, or all no-op) → no commit, `committed=false`.
|
||||
- **Re-ingest:** `cache.refresh_meta_repo` once when committed.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Bulk endpoint — happy path, one commit
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/api_metadata.py`
|
||||
- Test: `backend/tests/test_metadata_bulk_endpoint.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `backend/tests/test_metadata_bulk_endpoint.py`:
|
||||
|
||||
```python
|
||||
"""SLICE-5 — bulk metadata edit endpoint (PUC-2, §6.4/§6.5).
|
||||
|
||||
Through the real API: contributor+ gating (INV-4), set/add/remove ops,
|
||||
validation at the write boundary, one commit for N sidecars (D7), and
|
||||
partial-rejection reporting. Reuses the fake-Gitea harness.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import yaml
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import cache, db, gitea as gitea_mod
|
||||
from app.config import load_config
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401 (fixtures)
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
PID = "default"
|
||||
CID = "default"
|
||||
BASE = f"/api/projects/{PID}/collections/{CID}"
|
||||
|
||||
|
||||
def _refresh():
|
||||
cfg = load_config()
|
||||
asyncio.run(cache.refresh_meta_repo(cfg, gitea_mod.Gitea(cfg)))
|
||||
|
||||
|
||||
def _set_fields(schema):
|
||||
db.conn().execute(
|
||||
"UPDATE collections SET config_json = ? WHERE id = 'default'",
|
||||
(json.dumps({"fields": schema}),))
|
||||
|
||||
|
||||
def _seed_legacy(fake, slug, *, state="active", **front):
|
||||
fm = {"slug": slug, "title": slug.title(), "state": state, **front}
|
||||
body = yaml.safe_dump(fm, sort_keys=False).strip()
|
||||
fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.md")] = {
|
||||
"content": f"---\n{body}\n---\n\nBody.\n", "sha": slug}
|
||||
|
||||
|
||||
def _login_owner(client):
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
|
||||
|
||||
|
||||
def _sidecar(fake, slug):
|
||||
return yaml.safe_load(
|
||||
fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.meta.yaml")]["content"])
|
||||
|
||||
|
||||
def test_bulk_set_applies_to_all_and_one_commit(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_set_fields({"priority": {"type": "enum", "values": ["P0", "P1", "P2"]}})
|
||||
_seed_legacy(fake, "a", priority="P2")
|
||||
_seed_legacy(fake, "b", priority="P1")
|
||||
_refresh()
|
||||
_login_owner(client)
|
||||
commits_before = fake.change_files_calls
|
||||
r = client.post(f"{BASE}/meta/bulk",
|
||||
json={"slugs": ["a", "b"], "op": "set",
|
||||
"field": "priority", "value": "P0"})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert set(body["applied"]) == {"a", "b"}
|
||||
assert body["rejected"] == []
|
||||
assert body["committed"] is True
|
||||
# exactly one ChangeFiles commit covered both entries (D7)
|
||||
assert fake.change_files_calls - commits_before == 1
|
||||
assert _sidecar(fake, "a")["priority"] == "P0"
|
||||
assert _sidecar(fake, "b")["priority"] == "P0"
|
||||
```
|
||||
|
||||
> NOTE: the test asserts `fake.change_files_calls` — a counter on the fake Gitea. If the fake does not already expose one, Step 3 adds it (see Task 1 Step 3a).
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd backend && python -m pytest tests/test_metadata_bulk_endpoint.py::test_bulk_set_applies_to_all_and_one_commit -v`
|
||||
Expected: FAIL (404 — route not defined, or AttributeError on `change_files_calls`).
|
||||
|
||||
- [ ] **Step 3a: Add a commit counter to the fake Gitea (only if missing)**
|
||||
|
||||
Inspect the fake's `change_files` in `backend/tests/test_propose_vertical.py`. If it has no call counter, add one. Find the fake class's `change_files` method and increment a counter:
|
||||
|
||||
```python
|
||||
async def change_files(self, owner, repo, *, files, message, branch,
|
||||
author_name=None, author_email=None):
|
||||
self.change_files_calls = getattr(self, "change_files_calls", 0) + 1
|
||||
# ... existing body unchanged ...
|
||||
```
|
||||
|
||||
If a counter already exists under another name, use that name in the test instead and skip this step.
|
||||
|
||||
- [ ] **Step 3b: Implement the bulk route**
|
||||
|
||||
In `backend/app/api_metadata.py`, add the request model near `MetaEditBody`:
|
||||
|
||||
```python
|
||||
class BulkMetaBody(BaseModel):
|
||||
slugs: list[str]
|
||||
op: str
|
||||
field: str
|
||||
value: Any = None
|
||||
```
|
||||
|
||||
Add the pure op helper above `make_router` (module level):
|
||||
|
||||
```python
|
||||
def _apply_op(entry: Any, op: str, field: str, value: Any) -> Any:
|
||||
"""Return the new value for `field` after applying `op` to `entry`.
|
||||
|
||||
set → `value`; add/remove operate on the entry's current tags-list value
|
||||
for `field` (the route restricts add/remove to tags-type fields).
|
||||
"""
|
||||
if op == "set":
|
||||
return value
|
||||
current = metadata_mod.metadata_dict(entry).get(field) or []
|
||||
if not isinstance(current, list):
|
||||
current = [current]
|
||||
if op == "add":
|
||||
return current if value in current else [*current, value]
|
||||
if op == "remove":
|
||||
return [x for x in current if x != value]
|
||||
return value # unreachable; op validated by the route
|
||||
```
|
||||
|
||||
Add the route inside `make_router`, after the single-entry `edit_meta` route:
|
||||
|
||||
```python
|
||||
@router.post("/api/projects/{project_id}/collections/{collection_id}/meta/bulk")
|
||||
async def bulk_meta(
|
||||
project_id: str, collection_id: str,
|
||||
body: BulkMetaBody, request: Request,
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
if collections_mod.project_of_collection(collection_id) != project_id:
|
||||
raise HTTPException(404, "Collection not in project")
|
||||
if not auth.can_contribute_in_collection(viewer, collection_id):
|
||||
raise HTTPException(403, "Contributor access required to edit metadata")
|
||||
col = collections_mod.get_collection(collection_id)
|
||||
fields = (col or {}).get("fields") or {}
|
||||
if not fields:
|
||||
raise HTTPException(422, "Collection declares no editable fields")
|
||||
if not body.slugs:
|
||||
raise HTTPException(422, "Provide at least one entry")
|
||||
if body.op not in ("set", "add", "remove"):
|
||||
raise HTTPException(422, f"Unknown op: {body.op}")
|
||||
if body.field not in fields:
|
||||
raise HTTPException(422, f"Unknown field: {body.field}")
|
||||
if body.op in ("add", "remove") and fields[body.field].get("type") != "tags":
|
||||
raise HTTPException(422, f"op {body.op} requires a tags field")
|
||||
|
||||
org, repo = _content_repo()
|
||||
applied: list[str] = []
|
||||
rejected: list[dict[str, str]] = []
|
||||
all_ops: list[dict[str, Any]] = []
|
||||
for slug in body.slugs:
|
||||
md_path = _md_path(collection_id, slug)
|
||||
st = await metadata_mod.read_entry_from_git(gitea, org, repo, md_path)
|
||||
if st is None:
|
||||
rejected.append({"slug": slug, "reason": "not found"})
|
||||
continue
|
||||
new_value = _apply_op(st.entry, body.op, body.field, body.value)
|
||||
new_entry = metadata_mod.apply_values(st.entry, {body.field: new_value})
|
||||
problems = metadata_schema.validate(
|
||||
metadata_mod.metadata_dict(new_entry), fields)
|
||||
if problems:
|
||||
rejected.append({"slug": slug,
|
||||
"reason": "; ".join(p.message for p in problems)})
|
||||
continue
|
||||
applied.append(slug)
|
||||
if metadata_mod.metadata_dict(new_entry) != metadata_mod.metadata_dict(st.entry):
|
||||
all_ops.extend(metadata_mod.write_entry_files(md_path, new_entry, st))
|
||||
|
||||
committed = False
|
||||
if all_ops:
|
||||
n = len(applied)
|
||||
msg = f"Bulk {body.op} {body.field}: {n} entr{'y' if n == 1 else 'ies'}"
|
||||
try:
|
||||
await bot.commit_entry_files(
|
||||
viewer.as_actor(), org=org, repo=repo, files=all_ops,
|
||||
message=msg, branch="main")
|
||||
except GiteaError as e:
|
||||
raise HTTPException(502, f"Gitea: {e.detail}")
|
||||
committed = True
|
||||
await cache.refresh_meta_repo(config, gitea)
|
||||
return {"ok": True, "applied": applied,
|
||||
"rejected": rejected, "committed": committed}
|
||||
```
|
||||
|
||||
> `Problem` exposes `.message` (see `metadata_schema.Problem`/`as_dict`); confirm the attribute name when implementing and adjust the join if it differs.
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `cd backend && python -m pytest tests/test_metadata_bulk_endpoint.py::test_bulk_set_applies_to_all_and_one_commit -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/api_metadata.py backend/tests/test_metadata_bulk_endpoint.py backend/tests/test_propose_vertical.py
|
||||
git commit -m "feat(slice5): POST .../meta/bulk one-commit bulk metadata edit (§22.4a PUC-2)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Bulk add/remove tags
|
||||
|
||||
**Files:**
|
||||
- Test: `backend/tests/test_metadata_bulk_endpoint.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append:
|
||||
|
||||
```python
|
||||
def test_bulk_add_tag(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_set_fields({"tags": {"type": "tags"}})
|
||||
_seed_legacy(fake, "a", tags=["x"])
|
||||
_seed_legacy(fake, "b", tags=["x", "y"])
|
||||
_refresh()
|
||||
_login_owner(client)
|
||||
r = client.post(f"{BASE}/meta/bulk",
|
||||
json={"slugs": ["a", "b"], "op": "add",
|
||||
"field": "tags", "value": "y"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert set(r.json()["applied"]) == {"a", "b"}
|
||||
assert _sidecar(fake, "a")["tags"] == ["x", "y"]
|
||||
# idempotent: "b" already had y → unchanged, still no duplicate
|
||||
assert _sidecar(fake, "b")["tags"] == ["x", "y"] or "b" not in [
|
||||
# b may be a no-op write skip; the cache still shows ["x","y"]
|
||||
]
|
||||
|
||||
|
||||
def test_bulk_remove_tag(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_set_fields({"tags": {"type": "tags"}})
|
||||
_seed_legacy(fake, "a", tags=["x", "y"])
|
||||
_refresh()
|
||||
_login_owner(client)
|
||||
r = client.post(f"{BASE}/meta/bulk",
|
||||
json={"slugs": ["a"], "op": "remove",
|
||||
"field": "tags", "value": "x"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["applied"] == ["a"]
|
||||
assert _sidecar(fake, "a")["tags"] == ["y"]
|
||||
|
||||
|
||||
def test_bulk_add_remove_requires_tags_field(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_set_fields({"priority": {"type": "enum", "values": ["P0"]}})
|
||||
_seed_legacy(fake, "a", priority="P0")
|
||||
_refresh()
|
||||
_login_owner(client)
|
||||
r = client.post(f"{BASE}/meta/bulk",
|
||||
json={"slugs": ["a"], "op": "add",
|
||||
"field": "priority", "value": "z"})
|
||||
assert r.status_code == 422, r.text
|
||||
```
|
||||
|
||||
> Simplify the `test_bulk_add_tag` "b" assertion to whatever the no-op-skip semantics produce — verify the sidecar for "b" still reads `["x", "y"]` if a sidecar was written, and don't assert a sidecar exists for "b" if it was a pure no-op. Adjust after observing the first run.
|
||||
|
||||
- [ ] **Step 2: Run to verify they fail/pass appropriately**
|
||||
|
||||
Run: `cd backend && python -m pytest tests/test_metadata_bulk_endpoint.py -v -k "add or remove"`
|
||||
Expected: pass for the logic implemented in Task 1; fix the `test_bulk_add_tag` "b" assertion to match the no-op-skip behavior actually observed.
|
||||
|
||||
- [ ] **Step 3: Finalize assertions**
|
||||
|
||||
Edit the `test_bulk_add_tag` "b" branch to a concrete assertion based on the observed behavior (sidecar absent for a pure no-op, or present and equal to `["x","y"]`).
|
||||
|
||||
- [ ] **Step 4: Run to verify all pass**
|
||||
|
||||
Run: `cd backend && python -m pytest tests/test_metadata_bulk_endpoint.py -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/tests/test_metadata_bulk_endpoint.py
|
||||
git commit -m "test(slice5): bulk add/remove tag ops + tags-field guard"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Partial rejection, authz, op validation
|
||||
|
||||
**Files:**
|
||||
- Test: `backend/tests/test_metadata_bulk_endpoint.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append:
|
||||
|
||||
```python
|
||||
def test_bulk_partial_reject_invalid_value(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_set_fields({"priority": {"type": "enum", "values": ["P0", "P1"]}})
|
||||
_seed_legacy(fake, "a", priority="P1")
|
||||
_seed_legacy(fake, "missing-source", priority="P1") # has source
|
||||
_refresh()
|
||||
_login_owner(client)
|
||||
# invalid value rejects ALL (set value is global), so use a per-entry
|
||||
# rejection: one slug exists, one does not.
|
||||
r = client.post(f"{BASE}/meta/bulk",
|
||||
json={"slugs": ["a", "ghost"], "op": "set",
|
||||
"field": "priority", "value": "P0"})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["applied"] == ["a"]
|
||||
assert body["rejected"] == [{"slug": "ghost", "reason": "not found"}]
|
||||
assert body["committed"] is True
|
||||
assert _sidecar(fake, "a")["priority"] == "P0"
|
||||
|
||||
|
||||
def test_bulk_invalid_value_rejects_all_no_commit(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_set_fields({"priority": {"type": "enum", "values": ["P0", "P1"]}})
|
||||
_seed_legacy(fake, "a", priority="P1")
|
||||
_refresh()
|
||||
_login_owner(client)
|
||||
r = client.post(f"{BASE}/meta/bulk",
|
||||
json={"slugs": ["a"], "op": "set",
|
||||
"field": "priority", "value": "ZZZ"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["applied"] == []
|
||||
assert len(r.json()["rejected"]) == 1
|
||||
assert r.json()["committed"] is False
|
||||
assert ("wiggleverse", "meta", "main", "rfcs/a.meta.yaml") not in fake.files
|
||||
|
||||
|
||||
def test_bulk_forbidden_for_anonymous(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_set_fields({"priority": {"type": "enum", "values": ["P0"]}})
|
||||
_seed_legacy(fake, "a", priority="P0")
|
||||
_refresh()
|
||||
r = client.post(f"{BASE}/meta/bulk",
|
||||
json={"slugs": ["a"], "op": "set",
|
||||
"field": "priority", "value": "P0"})
|
||||
assert r.status_code == 403, r.text
|
||||
|
||||
|
||||
def test_bulk_unknown_field_and_op(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_set_fields({"priority": {"type": "enum", "values": ["P0"]}})
|
||||
_seed_legacy(fake, "a", priority="P0")
|
||||
_refresh()
|
||||
_login_owner(client)
|
||||
assert client.post(f"{BASE}/meta/bulk",
|
||||
json={"slugs": ["a"], "op": "set",
|
||||
"field": "nope", "value": "P0"}).status_code == 422
|
||||
assert client.post(f"{BASE}/meta/bulk",
|
||||
json={"slugs": ["a"], "op": "frobnicate",
|
||||
"field": "priority", "value": "P0"}).status_code == 422
|
||||
assert client.post(f"{BASE}/meta/bulk",
|
||||
json={"slugs": [], "op": "set",
|
||||
"field": "priority", "value": "P0"}).status_code == 422
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify pass**
|
||||
|
||||
Run: `cd backend && python -m pytest tests/test_metadata_bulk_endpoint.py -v`
|
||||
Expected: PASS (logic from Task 1 covers these). Fix any assertion mismatches.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/tests/test_metadata_bulk_endpoint.py
|
||||
git commit -m "test(slice5): bulk partial-reject, authz, validation guards"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Frontend API helper
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/api.js`
|
||||
|
||||
- [ ] **Step 1: Add the helper**
|
||||
|
||||
After `saveEntryMeta` in `frontend/src/api.js`:
|
||||
|
||||
```javascript
|
||||
// §22.4a SLICE-5 (PUC-2): apply one field op (set | add | remove) to many
|
||||
// entries at once — one commit server-side; returns { applied, rejected }.
|
||||
export async function bulkEntryMeta(projectId, collectionId, { slugs, op, field, value }) {
|
||||
const res = await fetch(
|
||||
`/api/projects/${projectId}/collections/${collectionId}/meta/bulk`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slugs, op, field, value }),
|
||||
},
|
||||
)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/api.js
|
||||
git commit -m "feat(slice5): bulkEntryMeta API helper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: BulkActionBar component
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/BulkActionBar.jsx`
|
||||
- Test: `frontend/src/components/BulkActionBar.test.jsx`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `frontend/src/components/BulkActionBar.test.jsx`:
|
||||
|
||||
```javascript
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import BulkActionBar from './BulkActionBar.jsx'
|
||||
|
||||
const FIELDS = {
|
||||
priority: { type: 'enum', values: ['P0', 'P1', 'P2'], label: 'Priority' },
|
||||
tags: { type: 'tags', label: 'Tags' },
|
||||
}
|
||||
|
||||
describe('BulkActionBar', () => {
|
||||
it('shows the selected count', () => {
|
||||
render(<BulkActionBar fields={FIELDS} count={3} onApply={() => {}} onClear={() => {}} />)
|
||||
expect(screen.getByText(/3 selected/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies a set op when an enum value is chosen', () => {
|
||||
const onApply = vi.fn()
|
||||
render(<BulkActionBar fields={FIELDS} count={2} onApply={onApply} onClear={() => {}} />)
|
||||
fireEvent.change(screen.getByLabelText(/set priority/i), { target: { value: 'P0' } })
|
||||
expect(onApply).toHaveBeenCalledWith({ op: 'set', field: 'priority', value: 'P0' })
|
||||
})
|
||||
|
||||
it('applies an add-tag op', () => {
|
||||
const onApply = vi.fn()
|
||||
render(<BulkActionBar fields={FIELDS} count={2} onApply={onApply} onClear={() => {}} />)
|
||||
fireEvent.change(screen.getByPlaceholderText(/tag…/i), { target: { value: 'checkout' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /add tag/i }))
|
||||
expect(onApply).toHaveBeenCalledWith({ op: 'add', field: 'tags', value: 'checkout' })
|
||||
})
|
||||
|
||||
it('calls onClear', () => {
|
||||
const onClear = vi.fn()
|
||||
render(<BulkActionBar fields={FIELDS} count={2} onApply={() => {}} onClear={onClear} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /clear/i }))
|
||||
expect(onClear).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `cd frontend && npx vitest run src/components/BulkActionBar.test.jsx`
|
||||
Expected: FAIL (module not found).
|
||||
|
||||
- [ ] **Step 3: Implement the component**
|
||||
|
||||
Create `frontend/src/components/BulkActionBar.jsx`:
|
||||
|
||||
```javascript
|
||||
import { useState } from 'react'
|
||||
|
||||
// §22.4a SLICE-5 (PUC-2, UX §5.3): the sticky bulk action bar shown when ≥1
|
||||
// catalog row is selected. Driven by the collection `fields:` schema — one
|
||||
// "Set <field>" control per enum field, and an Add/Remove tag control per
|
||||
// tags field. Each gesture calls onApply({ op, field, value }); the parent
|
||||
// (Catalog) sends one bulk request and re-fetches.
|
||||
|
||||
const labelFor = (name, def) =>
|
||||
def?.label || name.charAt(0).toUpperCase() + name.slice(1)
|
||||
|
||||
export default function BulkActionBar({ fields, count, onApply, onClear }) {
|
||||
const [tagValue, setTagValue] = useState('')
|
||||
const entries = Object.entries(fields || {})
|
||||
const tagField = entries.find(([, d]) => d.type === 'tags')
|
||||
|
||||
return (
|
||||
<div className="bulk-action-bar">
|
||||
<span className="bulk-count">{count} selected</span>
|
||||
{entries
|
||||
.filter(([, d]) => d.type === 'enum')
|
||||
.map(([name, def]) => (
|
||||
<label key={name} className="bulk-set">
|
||||
<span>Set {labelFor(name, def)}</span>
|
||||
<select
|
||||
aria-label={`Set ${labelFor(name, def)}`}
|
||||
value=""
|
||||
onChange={e => {
|
||||
if (e.target.value) onApply({ op: 'set', field: name, value: e.target.value })
|
||||
}}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{(def.values || []).map(v => <option key={v} value={v}>{v}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
))}
|
||||
{tagField && (
|
||||
<div className="bulk-tags">
|
||||
<input
|
||||
placeholder="tag…"
|
||||
value={tagValue}
|
||||
onChange={e => setTagValue(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!tagValue.trim()}
|
||||
onClick={() => { onApply({ op: 'add', field: tagField[0], value: tagValue.trim() }); setTagValue('') }}
|
||||
>
|
||||
Add tag
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!tagValue.trim()}
|
||||
onClick={() => { onApply({ op: 'remove', field: tagField[0], value: tagValue.trim() }); setTagValue('') }}
|
||||
>
|
||||
Remove tag
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button type="button" className="bulk-clear" onClick={onClear}>Clear</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `cd frontend && npx vitest run src/components/BulkActionBar.test.jsx`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/BulkActionBar.jsx frontend/src/components/BulkActionBar.test.jsx
|
||||
git commit -m "feat(slice5): BulkActionBar component (UX §5.3)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Wire selection + bulk bar into Catalog
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/Catalog.jsx`
|
||||
- Test: `frontend/src/components/Catalog.test.jsx`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to `frontend/src/components/Catalog.test.jsx` a test that, with a faceted collection and a contributor viewer, selecting a row reveals the bulk bar. Inspect the existing Catalog.test.jsx mock setup first (how it mocks `../api` `listRFCs`/`getCollection`) and mirror it. Skeleton:
|
||||
|
||||
```javascript
|
||||
it('reveals the bulk action bar when a row is selected (faceted + contributor)', async () => {
|
||||
// mock getCollection → { viewer: { can_contribute: true }, fields: { priority: { type: 'enum', values: ['P0','P1'] } } }
|
||||
// mock listRFCs → { items: [{ slug: 'a', title: 'A', state: 'active', tags: [] }], facets: {} }
|
||||
// render Catalog within the same providers/router the other tests use
|
||||
// click the row's selection checkbox
|
||||
// expect screen.getByText(/1 selected/i) to be in the document
|
||||
})
|
||||
```
|
||||
|
||||
Fill the mocks to match the file's existing pattern exactly.
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `cd frontend && npx vitest run src/components/Catalog.test.jsx`
|
||||
Expected: FAIL (no checkbox / no bulk bar).
|
||||
|
||||
- [ ] **Step 3: Implement in `Catalog.jsx`**
|
||||
|
||||
1. Imports:
|
||||
|
||||
```javascript
|
||||
import { bulkEntryMeta } from '../api'
|
||||
import BulkActionBar from './BulkActionBar.jsx'
|
||||
import { useToast } from '../context/...' // match how other components toast; see below
|
||||
```
|
||||
|
||||
> Check how `ToastHost` is consumed elsewhere (e.g. `grep useToast frontend/src`). If there's no hook, accept an `onToast`/use `window`-level host the app already wires. If toasting is awkward here, fall back to an inline message line in the bulk bar area. Do not invent a toast system.
|
||||
|
||||
2. Selection state + reset on collection/filter change:
|
||||
|
||||
```javascript
|
||||
const [selected, setSelected] = useState(() => new Set())
|
||||
```
|
||||
|
||||
Clear it in the collection-change effect (the `[version, pid, cid]` effect) and whenever the list re-fetches: add `setSelected(new Set())` alongside `setSelections({})` in the collection effect, and clear it after a successful bulk apply.
|
||||
|
||||
3. Toggle helper:
|
||||
|
||||
```javascript
|
||||
function toggleSelect(slug) {
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev)
|
||||
next.has(slug) ? next.delete(slug) : next.add(slug)
|
||||
return next
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
4. Show a checkbox per row **only in faceted mode and when `canContribute`**. The row is a `<Link>`; render the checkbox as a sibling before it inside a wrapper so the checkbox click doesn't navigate (`onClick={e => e.stopPropagation()}` on the checkbox, and don't nest it in the Link):
|
||||
|
||||
```jsx
|
||||
filtered.map(r => {
|
||||
const isActive = slug === r.slug
|
||||
const isSuper = r.state === 'super-draft'
|
||||
const selectable = faceted && canContribute
|
||||
return (
|
||||
<div key={r.slug} className={`catalog-row-wrap ${selectable ? 'selectable' : ''}`}>
|
||||
{selectable && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="row-select"
|
||||
aria-label={`select ${r.title}`}
|
||||
checked={selected.has(r.slug)}
|
||||
onChange={() => toggleSelect(r.slug)}
|
||||
/>
|
||||
)}
|
||||
<Link
|
||||
to={entryPath(pid, r.slug, cid)}
|
||||
className={`catalog-row ${isActive ? 'active' : ''} ${isSuper ? 'is-super' : ''}`}
|
||||
>
|
||||
{/* ...existing row internals unchanged... */}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
> Preserve the existing row internals (`row-top`, `row-id`, malformed marker, `row-title`, `row-tags`) verbatim inside the `<Link>`.
|
||||
|
||||
5. Apply handler:
|
||||
|
||||
```javascript
|
||||
async function applyBulk({ op, field, value }) {
|
||||
const slugs = [...selected]
|
||||
if (slugs.length === 0) return
|
||||
try {
|
||||
const res = await bulkEntryMeta(pid, cid, { slugs, op, field, value })
|
||||
if (res.rejected?.length) {
|
||||
// surface which entries failed; see toast note above
|
||||
showToast?.(`${res.applied.length} updated, ${res.rejected.length} skipped`)
|
||||
}
|
||||
setSelected(new Set())
|
||||
// re-fetch the list (re-run the facet effect): bump a local nonce or
|
||||
// re-call listRFCs directly. Simplest: replicate the list fetch here.
|
||||
const selObj = Object.fromEntries(
|
||||
Object.entries(selections).map(([f, set]) => [f, [...set]]))
|
||||
const d = await listRFCs(pid, cid, { selections: selObj, malformed: malformedOnly })
|
||||
setRfcs(d.items); setFacets(d.facets || {})
|
||||
} catch (e) {
|
||||
showToast?.(e.message || 'Bulk update failed')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
6. Render the bar above the list when `selected.size > 0`:
|
||||
|
||||
```jsx
|
||||
{faceted && canContribute && selected.size > 0 && (
|
||||
<BulkActionBar
|
||||
fields={fields}
|
||||
count={selected.size}
|
||||
onApply={applyBulk}
|
||||
onClear={() => setSelected(new Set())}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `cd frontend && npx vitest run src/components/Catalog.test.jsx`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Add minimal styling**
|
||||
|
||||
Add CSS for `.bulk-action-bar` (sticky, visible bar) and `.row-select` / `.catalog-row-wrap` (flex row) to the catalog stylesheet. Find where `.catalog-row` is styled (`grep -rn "catalog-row" frontend/src`) and add the new rules in the same file.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/Catalog.jsx frontend/src/components/Catalog.test.jsx frontend/src/styles
|
||||
git commit -m "feat(slice5): catalog row multi-select + bulk action bar wired (PUC-2)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Full suites green
|
||||
|
||||
- [ ] **Step 1: Backend**
|
||||
|
||||
Run: `cd backend && python -m pytest -q`
|
||||
Expected: all pass (prior 640 + new bulk tests).
|
||||
|
||||
- [ ] **Step 2: Frontend**
|
||||
|
||||
Run: `cd frontend && npx vitest run`
|
||||
Expected: all pass (prior 44 + new BulkActionBar + Catalog tests).
|
||||
|
||||
- [ ] **Step 3: Fix any regressions, then commit if anything changed.**
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Version bump + changelog
|
||||
|
||||
**Files:**
|
||||
- Modify: `VERSION`, `frontend/package.json`, `CHANGELOG.md`
|
||||
|
||||
- [ ] **Step 1: Bump version to 0.51.0**
|
||||
|
||||
Set `VERSION` to `0.51.0`; set `frontend/package.json#version` to `0.51.0` (mirror rule, SPEC §20).
|
||||
|
||||
- [ ] **Step 2: Add the changelog entry**
|
||||
|
||||
Prepend a `## 0.51.0 — 2026-06-07` minor entry to `CHANGELOG.md` describing the bulk endpoint + bulk bar, citing §22.4a SLICE-5 / PUC-2 and the design doc. No deployment upgrade steps required (additive; opt-in per collection via `fields:`, INV-5) — state that explicitly.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add VERSION frontend/package.json CHANGELOG.md
|
||||
git commit -m "release(slice5): v0.51.0 — bulk tag/untag metadata (§22.4a PUC-2 SLICE-5)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes
|
||||
|
||||
- **Spec coverage:** DoD = "multi-select + bulk bar (Task 5/6); `POST …/meta/bulk` applies set/add/remove as one commit (Task 1/2); partial-rejection reported (Task 3)." Tests `test_bulk_one_commit` ≈ Task 1, `test_partial_reject` ≈ Task 3 (traceability §8). INV-1/4/8 honored by reusing SLICE-4 sidecar write-through. INV-5 (no `fields:` → unchanged): bulk bar gated on `faceted` + the endpoint 422s with no `fields:`.
|
||||
- **Type consistency:** `bulkEntryMeta({slugs, op, field, value})` shape matches `BulkMetaBody` and `onApply({op, field, value})`. `_apply_op` is the single op interpreter.
|
||||
- **Open confirmations during execution:** (a) `metadata_schema.Problem` attribute for the human message (`.message` vs `.detail`); (b) the fake Gitea commit counter name; (c) the toast mechanism in the frontend (use existing or fall back to inline). Each is called out at its task.
|
||||
@@ -0,0 +1,627 @@
|
||||
# Solution Design: Corpus Tree — universal directory-tree left pane
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **Author(s)** | Ben Stull |
|
||||
| **Reviewers / approvers** | Ben Stull |
|
||||
| **Status** | `draft` |
|
||||
| **Version** | v0.2.0 |
|
||||
| **Source artifacts** | BDD corpus: §§1.9/4 below (this doc) · Prototype: brainstorm mockups, session 0081.0 (`.superpowers/brainstorm/`, not committed) · Reference: current `rfc-app` §7 Catalog + §22 three-tier + `DocsLayout` flyout nav · Supersedes: — |
|
||||
|
||||
**Change log**
|
||||
|
||||
| Date | Version | Change | By |
|
||||
| --- | --- | --- | --- |
|
||||
| 2026-06-06 | v0.1.0 | Initial draft (discovery session ohm 0081.0) | Ben Stull |
|
||||
| 2026-06-06 | v0.2.0 | Reworked to the restructured Solution Design standard (two-part front; Pain Points; Business Actors vs Product Personas; renumbered) | Ben Stull |
|
||||
|
||||
---
|
||||
|
||||
## 1. Business Context
|
||||
|
||||
*The business lens — solution-agnostic throughout. No mechanism is proposed until §2.*
|
||||
|
||||
### 1.1 Executive Summary
|
||||
|
||||
Organizations keep large, living bodies of documentation, and the value in them
|
||||
depends on people being able to find what they need and trust that it is current
|
||||
and collectively maintained. This design targets two outcomes: readers can locate
|
||||
any document by where it sits in a documentation body's own organization, and an
|
||||
organization can bring an existing body of documentation under collaborative,
|
||||
reviewed governance without disrupting how that documentation is already
|
||||
arranged. The benefit accrues to readers (faster, more confident access),
|
||||
contributors (a reviewed way to improve the docs), and the organization (a
|
||||
governed, trustworthy knowledge base).
|
||||
|
||||
### 1.2 Background
|
||||
|
||||
The platform already governs collaboratively-edited documents organized as a
|
||||
single shallow list within a body. Real organizational documentation — handbooks,
|
||||
runbooks, design libraries — is instead deeply structured, and most of it already
|
||||
exists as finished, authoritative content rather than passing through a
|
||||
proposal-to-acceptance flow. There is currently no way to bring such a body onto
|
||||
the platform's governance without flattening its structure, which is why
|
||||
structured documentation bodies stay off-platform today.
|
||||
|
||||
### 1.3 Business Actors / Roles
|
||||
|
||||
Real-world roles, independent of any product.
|
||||
|
||||
| Role | Responsible for (in the business) |
|
||||
| --- | --- |
|
||||
| Reader | Finds and reads documents to do their work; needs current, authoritative content |
|
||||
| Contributor | Proposes new documents and changes to existing ones |
|
||||
| Maintainer | Reviews proposed changes and decides what becomes authoritative |
|
||||
| Documentation steward | Owns an existing body of documentation and decides to bring it under collaborative governance |
|
||||
|
||||
### 1.4 Problem Statement
|
||||
|
||||
A reader cannot navigate a documentation body by its own structure, and an
|
||||
organization cannot place an existing structured body under collaborative
|
||||
governance without reorganizing it. The governance model assumes every document
|
||||
is one entry in a single flat list and passes through a proposal flow — neither
|
||||
of which holds for an established, deeply-organized documentation body whose
|
||||
documents already exist as authoritative content.
|
||||
|
||||
### 1.5 Pain Points
|
||||
|
||||
| # | Pain | Who feels it | Cost / frequency today |
|
||||
| --- | --- | --- | --- |
|
||||
| PP-1 | In a large body presented as one flat list, a reader can't tell where a document sits or browse by area | Reader | Every lookup in a sizeable body; slow, error-prone, gives up |
|
||||
| PP-2 | An existing structured body can't be brought under governance without rearranging its documents into a flat scheme | Documentation steward | Blocks adoption entirely for any living body — a rearrange is a non-starter |
|
||||
| PP-3 | Contributors to an existing body have no governed, reviewed way to propose changes tied to where each document lives | Contributor, Maintainer | Changes happen outside review, or not at all; no shared record of why |
|
||||
| PP-4 | When the documentation is briefly unreachable, the reader is left with nothing to orient by | Reader | Intermittent; erodes trust in the body as a dependable source |
|
||||
|
||||
### 1.6 Targeted Business Outcomes
|
||||
|
||||
| Outcome | Success metric | Baseline → Target | Guardrail (must not regress) | How / when measured |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Structured documentation bodies adopt the platform's governance | bodies of documentation hosted | 0 → ≥1 | existing bodies' usability unaffected | inspection at first onboarding |
|
||||
| Bringing a body under governance is a decision, not a project | preparatory rearrangement / setup required | a flatten/rework → none | — | inspection at onboarding (PP-2) |
|
||||
| Readers reliably reach documents in a large body | reader reaches the intended document | not feasible for structured bodies → routine | flat-body access unchanged | manual walkthrough (PP-1) |
|
||||
|
||||
> These are threshold/qualitative targets, not funnel metrics: this enables a new
|
||||
> class of hosted documentation rather than tuning a conversion surface.
|
||||
|
||||
### 1.7 Scope (business)
|
||||
|
||||
- **In scope:** readers navigating a body by its own organization; contributors
|
||||
proposing additions and changes anywhere in a body under review; stewards
|
||||
bringing an existing body under governance without rearranging it.
|
||||
- **Out of scope:** authoring tools beyond proposing/reviewing text documents;
|
||||
governance policy changes (who may review/accept) — unchanged.
|
||||
- **Non-goals:** becoming a general file store for non-document assets — the value
|
||||
is governed *documents*, not arbitrary binaries.
|
||||
|
||||
### 1.8 Assumptions · Constraints · Dependencies
|
||||
|
||||
- **Assumptions:** bodies brought under governance are predominantly prose
|
||||
documents (risk: a body that is mostly non-document assets gains little).
|
||||
- **Constraints:** the existing collaborative-governance model (proposal →
|
||||
review → acceptance) is reused, not redefined.
|
||||
- **Dependencies:** the steward can grant the platform access to the existing
|
||||
documentation body.
|
||||
|
||||
### 1.9 Business Use Cases
|
||||
|
||||
Solution-agnostic; no product, no technology.
|
||||
|
||||
**BUC-1 — As a reader, I can find and read a document by where it sits in the body, so that I get authoritative content without knowing an internal name.**
|
||||
|
||||
```gherkin
|
||||
Scenario: BUC-1 — A reader finds and reads a document in a structured body
|
||||
Given a body of documentation organized into sections
|
||||
When a reader looks for a particular document
|
||||
Then they can locate it by where it sits in that organization
|
||||
And read its current content
|
||||
```
|
||||
- **BUC-1 acceptance criteria:** the reader reaches the intended document and
|
||||
reads its current authoritative content.
|
||||
|
||||
**BUC-1a — As a reader, when the body is briefly unavailable, I am still oriented and never hit a dead end.**
|
||||
|
||||
```gherkin
|
||||
Scenario: BUC-1a — The documentation is temporarily unavailable
|
||||
Given the documentation cannot be reached for a moment
|
||||
When a reader tries to access it
|
||||
Then they are still shown what was last known to exist, or told clearly how to try again
|
||||
And are never left at an empty, unexplained dead end
|
||||
```
|
||||
|
||||
**BUC-2 — As a contributor, I can propose a change to a document, so that improvements are reviewed before they become authoritative.**
|
||||
|
||||
```gherkin
|
||||
Scenario: BUC-2 — A contributor proposes a change to a document
|
||||
Given a contributor authorized to change the documentation
|
||||
When they propose a change to an existing document
|
||||
Then the change enters review before it can become authoritative
|
||||
And the document is shown as having a change under review
|
||||
```
|
||||
- **BUC-2 acceptance criteria:** a reviewable proposal exists against that
|
||||
document; until accepted, the authoritative content is unchanged.
|
||||
|
||||
**BUC-2a — As a contributor, when my proposal can't be accepted as placed, I'm told why and nothing is half-done.**
|
||||
|
||||
```gherkin
|
||||
Scenario: BUC-2a — A proposed change cannot be accepted as placed
|
||||
Given a contributor proposing a document
|
||||
When the chosen placement conflicts with an existing document, or is not allowed
|
||||
Then the proposal is refused with a clear, specific reason
|
||||
And nothing is partially recorded
|
||||
```
|
||||
|
||||
**BUC-3 — As a contributor, I can introduce a new document anywhere in the body, so that the body grows where the content belongs.**
|
||||
|
||||
```gherkin
|
||||
Scenario: BUC-3 — A contributor introduces a new document anywhere in the body
|
||||
Given a contributor authorized to add documentation
|
||||
When they propose a new document at a place within the body
|
||||
Then it enters review as a draft in that place
|
||||
And on acceptance it becomes the authoritative document there
|
||||
```
|
||||
- **BUC-3 acceptance criteria:** a draft appears at the chosen place and is
|
||||
reviewable; on acceptance it is the authoritative document there.
|
||||
|
||||
**BUC-4 — As a documentation steward, I can bring an existing body under collaborative governance, so that it gains review and shared maintenance without disruption.**
|
||||
|
||||
```gherkin
|
||||
Scenario: BUC-4 — A steward brings an existing doc body under governance
|
||||
Given an organization with an existing body of documentation
|
||||
When that documentation is brought under collaborative governance
|
||||
Then all of its existing documents are immediately readable as authoritative
|
||||
And none of them had to be rearranged to make that possible
|
||||
```
|
||||
- **BUC-4 acceptance criteria:** every existing document is readable and treated
|
||||
as authoritative, with no rearrangement and no preparatory data entry.
|
||||
|
||||
---
|
||||
|
||||
## 2. Solution Proposal
|
||||
|
||||
Build software: make the platform's left-pane navigation a **universal
|
||||
directory-tree** that mirrors a content repository's own git directory structure,
|
||||
and address every document by its **path** rather than a flat slug — so a
|
||||
documentation body is navigated by its real structure while keeping the existing
|
||||
propose → review → accept governance on every file. The flat list becomes the
|
||||
degenerate "one folder" case of the tree; a deep body is the general case. A new
|
||||
`CorpusTree` left-pane component replaces the flat §7 Catalog as the *universal*
|
||||
idiom (one navigation model for every project type), with **two display modes** —
|
||||
*structure* (the directory tree) and *flat* (today's ranked list, engaged on
|
||||
search or a non-path sort) — so the four affordances the flat list provides
|
||||
(search, lifecycle state, sort, the contribution surface) all survive.
|
||||
|
||||
An existing body is onboarded as an ordinary registry project pointed at its
|
||||
repository, with **no content migration and no state backfill**: a document
|
||||
present with no lifecycle record is treated as authoritative ("no record =
|
||||
active").
|
||||
|
||||
**Why this approach, over the alternatives:**
|
||||
- *Do nothing / keep flat + rearrange repos* — rejected: PP-2 makes a rearrange a
|
||||
non-starter for a living body.
|
||||
- *A read-only documentation viewer* — rejected: delivers PP-1 but not PP-3
|
||||
(no governed contribution).
|
||||
- *A second, separate tree view beside the flat catalog* — rejected: forks the
|
||||
navigation idiom and doubles the surface; the tree generalizes the flat list
|
||||
rather than sitting beside it.
|
||||
|
||||
**Solution-specific scope / non-goals:**
|
||||
- *In:* path-addressed documents; a directory-tree read surface; the dual-mode
|
||||
pane; lifecycle by path; propose/edit/review at any path; zero-migration
|
||||
onboarding; legacy slug→path URL compatibility.
|
||||
- *Out:* lazy per-folder tree loading (a scale follow-on; v1 serves the full
|
||||
document tree from cache); a rich renderer for non-document files (listed but
|
||||
inert); changes to graduation / integer-ID assignment.
|
||||
- *Non-goals:* merging folders with collections (kept distinct — INV-3); a new
|
||||
project *type* (existing types render via the tree — D5).
|
||||
|
||||
---
|
||||
|
||||
## 3. Product Personas
|
||||
|
||||
| Product persona | In rfc-app | Maps to business role(s) |
|
||||
| --- | --- | --- |
|
||||
| Reader | Browses a project's corpus and opens documents | Reader |
|
||||
| Contributor | Holds collection `can_contribute`; proposes/edits docs at any path | Contributor, Maintainer |
|
||||
| Deployment operator | Registers a content repo as a project and flips the cutover flag | Documentation steward |
|
||||
|
||||
## 4. Product Use Cases
|
||||
|
||||
UX-level; steps are about the Product Personas (§3). Each links to the Business UC
|
||||
it realizes.
|
||||
|
||||
```gherkin
|
||||
Scenario: PUC-1 — Browse the directory tree in structure mode (realizes BUC-1)
|
||||
Given the left pane is in structure mode
|
||||
When the reader opens the project
|
||||
Then folders and files are shown in path order
|
||||
And each markdown file shows its lifecycle-state badge
|
||||
And folders expand and collapse, with expansion persisted across visits
|
||||
```
|
||||
|
||||
```gherkin
|
||||
Scenario: PUC-2 — Search or non-path sort flips to flat mode (realizes BUC-1)
|
||||
Given the pane is in structure mode
|
||||
When the reader types a search query or picks a non-path sort
|
||||
Then folders are hidden and a ranked flat list of matches is shown
|
||||
And clearing the search with a path sort returns the tree with folders restored
|
||||
```
|
||||
|
||||
```gherkin
|
||||
Scenario: PUC-3 — Open a document by its path (realizes BUC-1)
|
||||
When the reader selects a file in the tree
|
||||
Then the main column renders that document
|
||||
And the tree highlights the corresponding node
|
||||
And the document's lifecycle state is visible
|
||||
```
|
||||
|
||||
```gherkin
|
||||
Scenario: PUC-4 — Propose a new doc at a path (realizes BUC-3)
|
||||
Given a contributor with contribute capability
|
||||
When they choose "Propose new doc" and give a path like deploy/runbooks/rollback.md
|
||||
Then intermediate folders are created in the proposal branch
|
||||
And a PR is opened via the bot
|
||||
And a super-draft entry appears at that path
|
||||
```
|
||||
|
||||
```gherkin
|
||||
Scenario: PUC-5 — Propose an edit to an existing doc (realizes BUC-2)
|
||||
Given a contributor viewing an existing document
|
||||
When they propose an edit
|
||||
Then an edit PR is opened against that document's path
|
||||
And the file's node shows the in-review badge with the open PR reference
|
||||
```
|
||||
|
||||
```gherkin
|
||||
Scenario: PUC-6 — Filter and see lifecycle state (realizes BUC-1, BUC-2)
|
||||
Given files exist in active, in-review, and super-draft states
|
||||
When the reader enables the "in review" state filter
|
||||
Then only files with an open edit PR are listed
|
||||
```
|
||||
|
||||
```gherkin
|
||||
Scenario: PUC-7 — Star a doc; starred pins in flat mode (product-only)
|
||||
Given the reader has starred a file
|
||||
When the pane is in flat mode
|
||||
Then the starred file is pinned above the ranked results
|
||||
```
|
||||
|
||||
```gherkin
|
||||
Scenario: PUC-8 — Legacy slug URL redirects to its path (product-only, compat)
|
||||
When the reader navigates to a legacy /p/:pid/c/:cid/e/:slug URL
|
||||
Then they are redirected to the path-addressed URL for that entry
|
||||
And the document renders
|
||||
```
|
||||
|
||||
```gherkin
|
||||
Scenario: PUC-9 — Operator onboards an existing repo (realizes BUC-4)
|
||||
Given an existing doc repo and bot read+write access
|
||||
When the operator registers it as a project's content repo and flips the cutover flag
|
||||
Then every pre-existing file renders as active in the tree
|
||||
And no content was moved and no state was backfilled
|
||||
```
|
||||
|
||||
```gherkin
|
||||
Scenario: PUC-10 — Tree fetch fails (realizes BUC-1a)
|
||||
Given a previously cached tree exists
|
||||
When the tree-listing fetch to gitea fails
|
||||
Then the last good tree is served from cache
|
||||
And when no cache exists, a graceful empty state with a retry action is shown
|
||||
```
|
||||
|
||||
```gherkin
|
||||
Scenario: PUC-11 — Propose at an invalid path (realizes BUC-2a)
|
||||
When a contributor proposes a doc at an existing or out-of-repo path
|
||||
Then the proposal is rejected with a clear validation error
|
||||
And no branch or PR is left behind
|
||||
```
|
||||
|
||||
```gherkin
|
||||
Scenario: PUC-12 — Non-markdown files are listed but inert (product detail)
|
||||
Given the repo contains schemas/app.json
|
||||
When the tree is listed
|
||||
Then schemas/app.json appears in the tree
|
||||
But it has no lifecycle badge and opens no entry view
|
||||
```
|
||||
|
||||
## 5. UX Layout
|
||||
|
||||
### 5.1 Left pane — `CorpusTree` (serves PUC-1..PUC-7, PUC-12)
|
||||
|
||||
- **Purpose:** navigate a project's corpus and reach any document; surface
|
||||
lifecycle state and the contribution affordances.
|
||||
- **Layout (top → bottom):**
|
||||
- **Toolbar:** search input; lifecycle state filter-chips; sort selector
|
||||
(path · recent · title · id · state); `+ Propose new doc` action.
|
||||
- **Body — structure mode (default):** indented directory tree; folders with
|
||||
expand/collapse in path order; file rows show a state dot
|
||||
(active / in-review / super-draft) and a star marker; the active file is
|
||||
highlighted. Non-markdown files render dimmed and inert.
|
||||
- **Body — flat mode (search / non-path sort):** folders hidden; a ranked flat
|
||||
list of file rows (path as secondary text); starred pinned to top; pending
|
||||
super-drafts grouped.
|
||||
- **States:** happy: tree/list rendered · empty: "No documents yet" + propose CTA
|
||||
if permitted · loading: skeleton rows · error: stale tree if cached, else an
|
||||
empty state with Retry · permission: propose control hidden without
|
||||
`can_contribute`.
|
||||
- **Notifications:** inline validation error on invalid propose (PUC-11); existing
|
||||
PR/discussion notifications unchanged.
|
||||
|
||||
### 5.2 Main column (serves PUC-3, PUC-5)
|
||||
|
||||
Unchanged — rendered markdown + discussion + PR/contribution affordances —
|
||||
addressed by path instead of slug.
|
||||
|
||||
## 6. Technical Design
|
||||
|
||||
### 6.1 Invariants
|
||||
|
||||
- **INV-1:** An entry is addressed by its repo `path` within a
|
||||
`(project, collection)`. Slug addressing exists only as a legacy redirect.
|
||||
- **INV-2:** A markdown file present on `main` with no explicit lifecycle record
|
||||
is `active`. Onboarding requires no state backfill.
|
||||
- **INV-3:** Folders are organizational only. Access control is the collection's;
|
||||
a folder never carries permissions.
|
||||
- **INV-4:** Lifecycle records are keyed by `(collection, path)` and follow file
|
||||
renames on merge — no orphaned records survive a `git mv`.
|
||||
- **INV-5:** The framework names no deployment. The backing repo is supplied via
|
||||
the registry (`CLAUDE.md` separation-of-concerns).
|
||||
- **INV-6:** Non-markdown files are listed but carry no lifecycle and open no
|
||||
entry view.
|
||||
- **INV-7:** The tree pane never renders blank — stale cache or an explicit
|
||||
empty/retry state on fetch failure.
|
||||
|
||||
### 6.2 High-level architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
CT[CorpusTree pane] -->|GET tree / entry by path| API[API service]
|
||||
CT -->|propose / edit at path| API
|
||||
API --> CACHE[(TTL cache)]
|
||||
CACHE --> GT[Gitea git-trees / raw]
|
||||
API -->|branch / PR writes| BOT[Bot account]
|
||||
BOT --> GT
|
||||
API --> DB[(App DB: lifecycle, stars)]
|
||||
REG[rfc-registry] -->|project to content-repo| API
|
||||
WH[Gitea webhooks] -->|invalidate / reconcile| CACHE
|
||||
```
|
||||
|
||||
- **CorpusTree** — owns left-pane rendering and mode state; owns no source of
|
||||
truth; must never assume a flat namespace.
|
||||
- **API service** — owns the tree-listing and path-addressed entry/contribution
|
||||
endpoints; derives lifecycle state; must never write content except via the bot.
|
||||
- **App DB** — owns lifecycle records, stars, reviewed-marks keyed by
|
||||
`(collection, path)`; system of record for collaboration state.
|
||||
- **Gitea** — system of record for document content and PRs.
|
||||
- **Cache** — holds the materialized tree + per-path metadata; invalidated by the
|
||||
webhook/PR-merge signals used today (§4.1 reconciler pattern).
|
||||
|
||||
### 6.3 Data model & ownership
|
||||
|
||||
| Entity | Owned by | Key fields | System of record |
|
||||
| --- | --- | --- | --- |
|
||||
| Document (entry) | Gitea | `(project, collection, path)`, body | Gitea repo `main` |
|
||||
| Lifecycle record | App DB | `(collection, path)`, state, reviewed-by | App DB |
|
||||
| Star | App DB | `(user, collection, path)` | App DB |
|
||||
| Open-PR mapping | Gitea | `path` → PR number | Gitea |
|
||||
| Tree node (materialized) | Cache | `path`, type, `last_commit_at`, state, `open_pr`, `starred_by_me` | derived (cache) |
|
||||
|
||||
### 6.4 Interfaces & contracts
|
||||
|
||||
- **`GET /api/projects/{pid}/collections/{cid}/tree`** — in: pid, cid, viewer ·
|
||||
out: nodes `{path, type, last_commit_at, lifecycle_state, open_pr,
|
||||
starred_by_me}` (full markdown tree) · errors: `404` unknown project/collection;
|
||||
gitea failure → stale cache or `503` with a retriable marker (INV-7).
|
||||
- **Path-addressed entry read** — generalize `…/rfcs/{slug}` to a path
|
||||
(`…/entries/{path}`); out: existing entry payload; errors: `404`.
|
||||
- **Propose-at-path / edit / discussion** — generalize existing contribution/PR/
|
||||
discussion endpoints from `slug` to `path`; propose-new in: target path, body;
|
||||
out: PR ref; errors: `409` path exists, `422` invalid/out-of-repo path
|
||||
(PUC-11), `403` no `can_contribute`.
|
||||
|
||||
### 6.5 Per–Product-Use-Case design
|
||||
|
||||
#### PUC-1 — Browse the tree (realizes BUC-1; honors INV-2, INV-7)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor R as Reader
|
||||
participant CT as CorpusTree
|
||||
participant A as API
|
||||
participant C as Cache
|
||||
participant G as Gitea
|
||||
R->>CT: open project
|
||||
CT->>A: GET …/tree
|
||||
A->>C: get materialized tree
|
||||
alt cache hit
|
||||
C-->>A: tree
|
||||
else miss
|
||||
A->>G: git trees + last-commit
|
||||
G-->>A: entries
|
||||
A->>A: derive state (no record = active, INV-2)
|
||||
A->>C: store
|
||||
end
|
||||
A-->>CT: nodes
|
||||
CT-->>R: structure-mode tree (or stale/empty per INV-7)
|
||||
```
|
||||
|
||||
- **Implementation:** materialize the markdown tree once per cache cycle; join
|
||||
per-path lifecycle/star records; "no record = active." Folder expansion is
|
||||
client state (localStorage keyed by project/collection).
|
||||
|
||||
#### PUC-4 — Propose a new doc at a path (realizes BUC-3; honors INV-1, INV-3)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor Cn as Contributor
|
||||
participant CT as CorpusTree
|
||||
participant A as API
|
||||
participant B as Bot
|
||||
participant G as Gitea
|
||||
Cn->>CT: + Propose new doc (path)
|
||||
CT->>A: propose-at-path(path, body)
|
||||
A->>A: validate path (unique, in-repo) else 409/422
|
||||
A->>B: create branch + file (mkdir -p path)
|
||||
B->>G: commit + open PR
|
||||
G-->>A: PR ref
|
||||
A-->>CT: super-draft at path
|
||||
```
|
||||
|
||||
- **Implementation:** reuse the existing bot/branch/PR flow; only the target path
|
||||
and intermediate-folder creation change. PUC-5 (edit), PUC-6 (filter), PUC-8
|
||||
(redirect) reuse existing flows with `slug`→`path` substitution and need no new
|
||||
sequence.
|
||||
|
||||
### 6.6 Non-functional requirements & cross-cutting concerns
|
||||
|
||||
- **Security & privacy:** read follows existing project/collection visibility;
|
||||
propose gated by `can_contribute` (INV-3); all writes via the bot; secrets stay
|
||||
references, never bytes (§6.3-handbook). No new PII.
|
||||
- **Performance & scale:** v1 returns the full markdown tree from the TTL cache;
|
||||
cold build is one git-trees call + a last-commit join. Lazy per-folder loading
|
||||
is the scale follow-on if a body's tree is large enough to hurt cold-build
|
||||
latency.
|
||||
- **Availability & resilience:** stale-cache fallback + empty/retry state
|
||||
(INV-7); no new hard dependency beyond what the corpus already needs.
|
||||
- **Observability:** log tree cold-builds, cache hit/miss, gitea-failure
|
||||
fallbacks; reuse existing health signals.
|
||||
- **Accessibility:** the tree uses ARIA `tree`/`treeitem` roles with keyboard
|
||||
expand/collapse and roving focus; state conveyed by text/label, not color alone.
|
||||
|
||||
### 6.7 Key decisions & alternatives considered
|
||||
|
||||
| Decision | Chosen | Alternatives considered | Why chosen |
|
||||
| --- | --- | --- | --- |
|
||||
| D1 Interaction model | Full governance lifecycle on any file | read-only viewer; read + light comments | operator intent; reuses existing machinery |
|
||||
| D2 Topology | Tree becomes universal | tree as a second view; tree only for a new type | one navigation model; no idiom fork |
|
||||
| D3 Entry identity | Path-addressed | keep slug + side table of paths | path is the natural key for a real body |
|
||||
| D4 Folders vs collections | Distinct | merge folders into collections | collections are access-control, folders are structure |
|
||||
| D5 Project type | No new type | a `tree`/`docs` type | "universal" means all types render via tree |
|
||||
| D6 Build approach | New `CorpusTree`, port flat mode, retire Catalog | evolve Catalog in place; promote DocsLayout flyout | clean dual-mode boundary; safe staged cutover |
|
||||
| D7 Existing files | "No record = active" | backfill a record per file at onboard | zero-migration onboarding |
|
||||
| D8 Affordances | Keep all four via dual mode | drop sort/pending on a tree | operator marked all non-negotiable |
|
||||
| D9 Routing | `e/*` splat + `?pr=` + legacy shim | encode path in slug; new `/f/` segment | least collision; reuses redirect pattern |
|
||||
|
||||
### 6.8 Testing strategy
|
||||
|
||||
Tests are part of each slice (not a follow-up).
|
||||
|
||||
- **Unit (vitest):** `CorpusTree` mode-switching (search→flat, sort→flat,
|
||||
clear→structure), badge rendering, expand/collapse persistence, starred-pin,
|
||||
flat-mode parity with the retired Catalog.
|
||||
- **Backend (DI at boundaries):** tree endpoint state derivation (INV-2), open-PR
|
||||
detection, path-addressed read/contribution, slug→path migration, rename
|
||||
reconciliation (INV-4).
|
||||
- **Two-tier:** Tier-1 local-Docker gitea for branch/PR flows; Tier-2 PPE for the
|
||||
onboard-a-real-repo path (SLICE-4). e2e: browse → open; propose-at-path → PR →
|
||||
merge → state transitions; search flatten; legacy redirect.
|
||||
|
||||
### 6.9 Failure modes, rollback & flags
|
||||
|
||||
- **Failure:** gitea unavailable → **behavior:** stale cache or empty/retry
|
||||
(INV-7) → **rollback:** none needed.
|
||||
- **Failure:** a project regresses on the new pane → **behavior/rollback:** flip
|
||||
the **per-project-type cutover flag** off; the Catalog renders again (not
|
||||
deleted until parity is proven).
|
||||
- **Feature flag:** per-project-type cutover flag, default **off**; deployments
|
||||
enable per project after validating parity.
|
||||
|
||||
## 7. Delivery Plan
|
||||
|
||||
### 7.1 Approach / strategy
|
||||
|
||||
Riskiest-foundation-first: land the path/state model and tree read behind the
|
||||
existing flat UI (no user-visible change), then the dual-mode UI behind a flag,
|
||||
then contribution-at-path, then onboard a real body. Per the handbook execution
|
||||
convention, **each slice is its own coding session** — plan just-in-time →
|
||||
execute → verify → ship → merge + version bump — in dependency order; this design
|
||||
pass happens once and is amended only if a slice proves it wrong. The flag keeps
|
||||
every intermediate state shippable.
|
||||
|
||||
### 7.2 Slicing plan
|
||||
|
||||
#### SLICE-1 — Backend foundation → completes (no user-visible PUC)
|
||||
- **Depends on:** —
|
||||
- **DoD:** path-keyed lifecycle + slug→path migration + "no record = active"
|
||||
(INV-2) + tree endpoint + path-addressed reads; Catalog still renders; backend
|
||||
tests green.
|
||||
|
||||
#### SLICE-2 — `CorpusTree` frontend → completes PUC-1, PUC-2, PUC-3, PUC-6, PUC-7, PUC-8, PUC-10, PUC-12
|
||||
- **Depends on:** SLICE-1
|
||||
- **DoD:** dual-mode pane behind the cutover flag; path routing + legacy shim;
|
||||
flat-mode parity; Catalog retired once a project is flipped; unit + e2e green.
|
||||
|
||||
#### SLICE-3 — Contribution-at-path → completes PUC-4, PUC-5, PUC-11
|
||||
- **Depends on:** SLICE-1
|
||||
- **DoD:** propose/edit/discussion at arbitrary paths; rename reconciliation
|
||||
(INV-4); validation (BUC-2a); tests green.
|
||||
|
||||
#### SLICE-4 — Onboard an existing doc body → completes PUC-9 (BUC-4)
|
||||
- **Depends on:** SLICE-2, SLICE-3
|
||||
- **DoD:** a real external repo registered + bot access + flag flipped; all
|
||||
pre-existing files render active with zero migration; Tier-2 PPE validation.
|
||||
|
||||
### 7.3 Rollout / launch plan
|
||||
|
||||
Pre-v1, single-prod: each slice ships to prod on merge with its CHANGELOG upgrade
|
||||
steps; the per-project-type flag defaults off, so deployments opt in per project.
|
||||
Rollback trigger: flag off → Catalog returns (§6.9).
|
||||
|
||||
### 7.4 Risks & mitigations
|
||||
|
||||
| Risk | Likelihood / impact | Mitigation |
|
||||
| --- | --- | --- |
|
||||
| Full-tree fetch slow on a large body | M / M | TTL cache; lazy per-folder loading as a defined follow-on |
|
||||
| Catalog flat-mode parity gaps after cutover | M / H | port logic verbatim; flag-gated per project; keep Catalog until parity proven |
|
||||
| Rename leaves orphan state records | M / M | webhook reconciliation (INV-4) + test |
|
||||
| slug→path migration mis-maps non-`document` entry folders | L / M | resolve entry folder per project type, not hardcoded; migration test |
|
||||
|
||||
## 8. Traceability matrix
|
||||
|
||||
| Pain | Business UC | Product UC | Slice | Tests |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| PP-1 | BUC-1 | PUC-1, PUC-2, PUC-3, PUC-6, PUC-7 | SLICE-2 | `test_tree_*`, `corpustree.*.test` |
|
||||
| PP-4 | BUC-1a | PUC-10 | SLICE-2 | `test_tree_fallback_*` |
|
||||
| PP-3 | BUC-2 | PUC-5, PUC-6 | SLICE-3 | `test_edit_at_path_*` |
|
||||
| PP-3 | BUC-2a | PUC-11 | SLICE-3 | `test_propose_validation_*` |
|
||||
| PP-3 | BUC-3 | PUC-4 | SLICE-3 | `test_propose_at_path_*` |
|
||||
| PP-2 | BUC-4 | PUC-9 | SLICE-4 | e2e `onboard_repo_*` |
|
||||
| PP-1 | (compat) | PUC-8 | SLICE-2 | `test_legacy_redirect_*` |
|
||||
| PP-1 | (detail) | PUC-12 | SLICE-2 | `test_tree_non_markdown_*` |
|
||||
|
||||
## 9. Open Questions & Decisions log
|
||||
|
||||
**Open**
|
||||
|
||||
| # | Question | Owner | Blocks |
|
||||
| --- | --- | --- | --- |
|
||||
| — | none outstanding | — | — |
|
||||
|
||||
**Resolved**
|
||||
|
||||
| # | Decision | Resolution | Date |
|
||||
| --- | --- | --- | --- |
|
||||
| D1 | Interaction model | Full governance lifecycle on any file | 2026-06-06 |
|
||||
| D2 | Topology | Tree becomes the universal left pane | 2026-06-06 |
|
||||
| D3 | Entry identity | Path-addressed; slug is legacy-redirect-only | 2026-06-06 |
|
||||
| D4 | Folders vs collections | Kept distinct | 2026-06-06 |
|
||||
| D5 | Project type | No new type; existing types render via tree | 2026-06-06 |
|
||||
| D6 | Build approach | New `CorpusTree`; port flat mode; retire Catalog | 2026-06-06 |
|
||||
| D7 | Pre-existing files | "No record = active"; zero-migration onboarding | 2026-06-06 |
|
||||
| D8 | Affordances | Keep all four via dual display mode | 2026-06-06 |
|
||||
| D9 | Routing | `e/*` splat + `?pr=` + legacy shim | 2026-06-06 |
|
||||
|
||||
Deferred (not open): lazy per-folder tree loading; raw/preview view for
|
||||
non-markdown files.
|
||||
|
||||
## 10. Glossary & References
|
||||
|
||||
- **Structure mode** — left pane showing the directory tree in path order.
|
||||
- **Flat mode** — left pane showing a ranked flat list (search/non-path sort);
|
||||
the retired Catalog's behavior.
|
||||
- **Entry** — a markdown document, addressed by its repo path within a
|
||||
`(project, collection)`.
|
||||
- **Lifecycle record** — app-DB row keyed by `(collection, path)` carrying
|
||||
non-default state; absence means `active`.
|
||||
- **References:** `SPEC.md` §7 (catalog), §22 (three-tier), §8 (revision), §13
|
||||
(graduation), §20 (versioning); `CLAUDE.md` (separation-of-concerns);
|
||||
handbook `solution-design/GUIDE.md` (this doc's standard).
|
||||
@@ -0,0 +1,40 @@
|
||||
import { waitForLatestOtc, clearMailpit } from './mailpit.js'
|
||||
|
||||
// Sign in and leave the rfc_session cookie on the page's browser context
|
||||
// (page.request shares the page context's cookie jar, so a subsequent
|
||||
// page.goto is authenticated). Two paths, chosen by environment:
|
||||
//
|
||||
// * DEPLOYED (PPE): when E2E_TEST_AUTH_SECRET is set, use the gated
|
||||
// `/auth/test/login` shortcut (v0.52.0). The deployed env has no
|
||||
// Mailpit sink to read an OTC code from, so the suite presents the
|
||||
// shared secret and the server mints an owner session for the one
|
||||
// configured E2E_TEST_AUTH_EMAIL. See backend/app/main.py.
|
||||
// * LOCAL (Tier-1 docker stack): no secret set → the original §6.2 OTC
|
||||
// path through Mailpit. The Tier-1 backend-seed pre-grants
|
||||
// e2e-owner@example.test as a deployment owner, so signing in as that
|
||||
// address yields write access (SLICE-4/5).
|
||||
//
|
||||
// OWNER_EMAIL is the identity both paths sign in as: E2E_OWNER_EMAIL when
|
||||
// set (PPE points it at E2E_TEST_AUTH_EMAIL), else the Tier-1 default.
|
||||
export const OWNER_EMAIL =
|
||||
process.env.E2E_OWNER_EMAIL || 'e2e-owner@example.test'
|
||||
|
||||
export async function signIn(page, email) {
|
||||
const secret = process.env.E2E_TEST_AUTH_SECRET
|
||||
if (secret) {
|
||||
const r = await page.request.post('/auth/test/login', {
|
||||
data: { email },
|
||||
headers: { 'X-Test-Auth-Secret': secret },
|
||||
})
|
||||
if (!r.ok()) throw new Error(`test-auth login failed: ${r.status()} ${await r.text()}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Local Tier-1 OTC path through Mailpit.
|
||||
await clearMailpit()
|
||||
const req = await page.request.post('/auth/otc/request', { data: { email } })
|
||||
if (!req.ok()) throw new Error(`otc request failed: ${req.status()}`)
|
||||
const code = await waitForLatestOtc(email)
|
||||
const verify = await page.request.post('/auth/otc/verify', { data: { email, code } })
|
||||
if (!verify.ok()) throw new Error(`otc verify failed: ${verify.status()}`)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Shared Playwright fixtures for the deployed-environment harness.
|
||||
//
|
||||
// Pre-record a cookie-consent choice via addInitScript so the §14.5
|
||||
// cookie-consent banner NEVER renders. The banner is fixed to the bottom
|
||||
// of the viewport and intercepts pointer events over the catalog footer
|
||||
// (the row-select checkboxes SLICE-5 clicks). The previous approach —
|
||||
// dismiss it after navigation (lib/ui.js dismissCookies) — raced the
|
||||
// banner's render on the slower deployed edge (PPE): dismissCookies ran
|
||||
// before the banner mounted, found nothing to remove, and the banner then
|
||||
// appeared and swallowed the row clicks. Recording consent at
|
||||
// document-start (before the app's scripts read `hasChosen()`) means the
|
||||
// banner's `open` state initialises false and it never mounts — no race.
|
||||
//
|
||||
// Storage shape mirrors lib/consent.js (LS_KEY 'rfc-app.cookie-consent.v1';
|
||||
// a non-null recorded_at == "the user has chosen"). Environment-agnostic:
|
||||
// the init script runs on whatever origin the test navigates to (PPE or
|
||||
// the Tier-1 localhost stack).
|
||||
import { test as base, expect } from '@playwright/test'
|
||||
|
||||
const CONSENT = JSON.stringify({
|
||||
essential: true,
|
||||
analytics: false,
|
||||
other: false,
|
||||
recorded_at: '2000-01-01T00:00:00.000Z',
|
||||
})
|
||||
|
||||
export const test = base.extend({
|
||||
context: async ({ context }, use) => {
|
||||
await context.addInitScript((value) => {
|
||||
try {
|
||||
window.localStorage.setItem('rfc-app.cookie-consent.v1', value)
|
||||
} catch {
|
||||
// localStorage unavailable — fall back to lib/ui.js dismissCookies.
|
||||
}
|
||||
}, CONSENT)
|
||||
await use(context)
|
||||
},
|
||||
})
|
||||
|
||||
export { expect }
|
||||
@@ -0,0 +1,18 @@
|
||||
// Dismiss the cookie-consent banner if it's showing. It is fixed to the bottom
|
||||
// of the viewport and intercepts pointer events over the catalog footer (where
|
||||
// row-select checkboxes live), so tests that click there must clear it first.
|
||||
export async function dismissCookies(page) {
|
||||
const banner = page.locator('.cookie-consent-banner')
|
||||
if (await banner.count()) {
|
||||
// Click to persist the consent choice so it doesn't reappear on
|
||||
// later navigation...
|
||||
await page.getByRole('button', { name: 'Save choice' }).click().catch(() => {})
|
||||
await banner.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {})
|
||||
// ...then forcibly remove any node still in the DOM. The dismiss
|
||||
// click occasionally doesn't land before a test clicks a catalog
|
||||
// footer checkbox (flaky over the deployed edge), and a lingering
|
||||
// fixed banner intercepts those pointer events. Removing the node
|
||||
// makes the dismissal deterministic.
|
||||
await banner.evaluate((el) => el.remove()).catch(() => {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { test, expect } from './lib/fixtures.js'
|
||||
import { signIn, OWNER_EMAIL } from './lib/auth.js'
|
||||
import { dismissCookies } from './lib/ui.js'
|
||||
|
||||
// §22.4a Configurable Collection Metadata — end-to-end browser coverage for the
|
||||
// three UI slices, against the Tier-1 stack's faceted `bdd` collection (seeded
|
||||
// in testing/seed-gitea.sh with a fields: schema of priority(enum) + tags, and
|
||||
// three entries: checkout-guest [P0], checkout-returning [P1], search-facets [P0]).
|
||||
//
|
||||
// Tests run in order against a freshly-seeded stack: SLICE-3 reads first, then
|
||||
// SLICE-4 edits `checkout-returning`, then SLICE-5 bulk-edits the two P0 entries
|
||||
// (distinct rows — no cross-test interference within one run).
|
||||
|
||||
const BDD = '/p/ohm/c/bdd'
|
||||
|
||||
// SLICE-3 (PUC-3) — faceted left-pane filtering, anonymous/read-only.
|
||||
test('SLICE-3: faceted filter narrows the catalog by Priority', async ({ page }) => {
|
||||
await page.goto(BDD)
|
||||
await dismissCookies(page)
|
||||
const catalog = page.locator('aside.catalog')
|
||||
|
||||
// All three entries are listed initially.
|
||||
await expect(catalog.getByText('Guest checkout')).toBeVisible()
|
||||
await expect(catalog.getByText('Returning-customer checkout')).toBeVisible()
|
||||
await expect(catalog.getByText('Faceted search')).toBeVisible()
|
||||
|
||||
// The Priority facet renders with the seeded P0 count (2 entries).
|
||||
const p0 = catalog.locator('.facet-value', { hasText: 'P0' })
|
||||
await expect(p0.locator('.facet-count')).toHaveText('2')
|
||||
|
||||
// Selecting P0 re-fetches server-side and drops the lone P1 entry.
|
||||
await p0.getByRole('checkbox').check()
|
||||
await expect(catalog.getByText('Returning-customer checkout')).toHaveCount(0)
|
||||
await expect(catalog.getByText('Guest checkout')).toBeVisible()
|
||||
await expect(catalog.getByText('Faceted search')).toBeVisible()
|
||||
})
|
||||
|
||||
// SLICE-4 (PUC-1) — single-entry metadata edit via the detail panel (authed).
|
||||
test('SLICE-4: edit one entry\'s priority from the detail panel', async ({ page }) => {
|
||||
await signIn(page, OWNER_EMAIL)
|
||||
await page.goto(`${BDD}/e/checkout-returning`)
|
||||
await dismissCookies(page)
|
||||
|
||||
const panel = page.locator('.metadata-fields-panel')
|
||||
await expect(panel).toBeVisible()
|
||||
// Seeded at P1; change to P2 and save (direct sidecar commit, D7).
|
||||
await expect(panel.locator('#mf-priority')).toHaveValue('P1')
|
||||
await panel.locator('#mf-priority').selectOption('P2')
|
||||
await panel.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(panel.getByText('Saved')).toBeVisible()
|
||||
|
||||
// Persisted across a reload (read back from the committed sidecar).
|
||||
await page.reload()
|
||||
await expect(page.locator('.metadata-fields-panel #mf-priority')).toHaveValue('P2')
|
||||
})
|
||||
|
||||
// SLICE-5 (PUC-2) — multi-select + bulk action bar, one commit (authed).
|
||||
test('SLICE-5: bulk-set priority on multiple selected entries', async ({ page }) => {
|
||||
await signIn(page, OWNER_EMAIL)
|
||||
await page.goto(BDD)
|
||||
await dismissCookies(page)
|
||||
const catalog = page.locator('aside.catalog')
|
||||
await expect(catalog.getByText('Guest checkout')).toBeVisible()
|
||||
|
||||
// Select the two P0 entries.
|
||||
await catalog.getByLabel('select Guest checkout').check()
|
||||
await catalog.getByLabel('select Faceted search').check()
|
||||
|
||||
// The sticky bulk bar appears with the selected count.
|
||||
const bar = page.locator('.bulk-action-bar')
|
||||
await expect(bar.getByText('2 selected')).toBeVisible()
|
||||
|
||||
// Set priority P1 across both → one commit; a toast reports the result.
|
||||
await bar.getByLabel('Set Priority').selectOption('P1')
|
||||
await expect(page.getByText(/2 updated/)).toBeVisible()
|
||||
|
||||
// The change is reflected server-side: the Priority P1 facet now counts the
|
||||
// two newly-updated entries plus the pre-existing P1 (checkout-returning was
|
||||
// P1 at seed; SLICE-4 may have moved it — assert at least the two we set).
|
||||
await expect(catalog.locator('.facet-value', { hasText: 'P1' }).locator('.facet-count'))
|
||||
.not.toHaveText('0')
|
||||
})
|
||||
@@ -1,9 +1,23 @@
|
||||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
// The metadata specs sign in, navigate, and (SLICE-4/5) write real commits,
|
||||
// so a handful of steps are timing-sensitive: the cookie-consent banner's
|
||||
// dismiss animation, first-render of the detail panel, and the round trip
|
||||
// after a write. These flake intermittently on a busy local box and more so
|
||||
// against a deployed host (network latency). `retries` makes the suite robust
|
||||
// to that (and finally makes `trace: 'on-first-retry'` meaningful); the
|
||||
// timeouts are bumped a notch for deployed runs over the public edge.
|
||||
const DEPLOYED = !!process.env.E2E_TEST_AUTH_SECRET
|
||||
|
||||
export default defineConfig({
|
||||
testDir: '.',
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 10_000 },
|
||||
timeout: DEPLOYED ? 60_000 : 45_000,
|
||||
expect: { timeout: DEPLOYED ? 20_000 : 12_000 },
|
||||
retries: 2,
|
||||
// The metadata specs run in order against one seeded collection and write
|
||||
// real commits (SLICE-4/5); parallel workers would race on shared state —
|
||||
// and on a deployed host, on concurrent git pushes through the bot. Serialize.
|
||||
workers: 1,
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL || 'http://localhost:8080',
|
||||
trace: 'on-first-retry',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"private": true,
|
||||
"version": "0.42.0",
|
||||
"version": "0.52.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -150,6 +150,56 @@
|
||||
.row-title { font-size: var(--text-base); margin-top: 2px; }
|
||||
.catalog-row.is-super .row-title { color: var(--c-gray-500); }
|
||||
.row-tags { font-size: var(--text-xs); color: var(--c-gray-500); margin-top: 2px; }
|
||||
.row-malformed { font-size: var(--text-2xs); color: var(--c-warning-accent); font-weight: 600; }
|
||||
|
||||
/* §22.4a SLICE-5 — row multi-select + bulk action bar (PUC-2, §5.3). */
|
||||
.catalog-row-wrap { display: flex; align-items: flex-start; }
|
||||
.catalog-row-wrap .row-select { margin: 10px 0 0 10px; flex: none; }
|
||||
.catalog-row-wrap .catalog-row { flex: 1; min-width: 0; }
|
||||
.bulk-action-bar {
|
||||
position: sticky; top: 0; z-index: 2;
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
|
||||
padding: 8px 14px; margin: 0;
|
||||
background: var(--c-gray-150); border-bottom: 1px solid var(--c-gray-300);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.bulk-action-bar .bulk-count { font-weight: 700; }
|
||||
.bulk-action-bar .bulk-set { display: flex; align-items: center; gap: 4px; }
|
||||
.bulk-action-bar .bulk-tags { display: flex; align-items: center; gap: 4px; }
|
||||
.bulk-action-bar .bulk-tags input { width: 90px; }
|
||||
.bulk-action-bar .bulk-clear { margin-left: auto; }
|
||||
|
||||
/* §22.4a SLICE-3 — faceted left-pane filters (§5.1). */
|
||||
.facets {
|
||||
padding: 8px 14px;
|
||||
border-bottom: 1px solid var(--c-gray-150);
|
||||
}
|
||||
.facet-group { margin-bottom: 8px; }
|
||||
.facet-header {
|
||||
background: none; border: 0; cursor: pointer; width: 100%; text-align: left;
|
||||
font-size: var(--text-xs); font-weight: 700; color: var(--c-gray-600);
|
||||
text-transform: uppercase; letter-spacing: 0.04em; padding: 4px 0;
|
||||
}
|
||||
.facet-values { display: flex; flex-direction: column; gap: 2px; }
|
||||
.facet-value {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-size: var(--text-xs); color: var(--c-gray-600); cursor: pointer;
|
||||
}
|
||||
.facet-value-label { flex: 1; }
|
||||
.facet-count { color: var(--c-gray-500); font-variant-numeric: tabular-nums; }
|
||||
.facet-value-search {
|
||||
width: 100%; margin: 4px 0; font-size: var(--text-xs);
|
||||
padding: 2px 6px; border: 1px solid var(--c-gray-150); border-radius: var(--radius-sm);
|
||||
}
|
||||
.facet-clear {
|
||||
background: none; border: 0; cursor: pointer; padding: 0 0 6px;
|
||||
font-size: var(--text-xs); color: var(--c-ink); text-decoration: underline;
|
||||
}
|
||||
.facet-empty { font-size: var(--text-xs); color: var(--c-gray-500); }
|
||||
.facet-malformed {
|
||||
display: flex; align-items: center; gap: 6px; margin-top: 6px;
|
||||
font-size: var(--text-xs); color: var(--c-warning-accent); cursor: pointer;
|
||||
}
|
||||
|
||||
.catalog-pending {
|
||||
border-top: 1px solid var(--c-gray-150);
|
||||
@@ -2615,3 +2665,25 @@ select:focus-visible,
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* §22 S4 — the collection directory head (title + owner controls) and the
|
||||
role-aware empty state. The S2 directory shipped with semantic classnames
|
||||
and default styling; S4 adds the action row and the create CTA. */
|
||||
.directory-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.directory-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.directory-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@@ -448,16 +448,19 @@ function DeploymentLanding() {
|
||||
// OHM's "land in the corpus" UX is preserved; the directory appears only
|
||||
// when 2+ projects are visible to the caller. Visibility is per-caller
|
||||
// (§22.5), so the unlisted projects never count toward the directory.
|
||||
const { projects, defaultProjectId, loading } = useDeployment()
|
||||
const { projects, defaultProjectId, defaultProjectReadable, loading } = useDeployment()
|
||||
if (loading) {
|
||||
return <main className="chrome-pane"><div className="boot">Loading…</div></main>
|
||||
}
|
||||
if (projects.length === 1) {
|
||||
return <Navigate to={`/p/${projects[0].id}/`} replace />
|
||||
}
|
||||
if (projects.length === 0 && defaultProjectId) {
|
||||
// No enumerable projects but a default exists (e.g. an anon hitting a
|
||||
// deployment whose only project is unlisted-but-default) — land there.
|
||||
if (projects.length === 0 && defaultProjectReadable && defaultProjectId) {
|
||||
// No enumerable projects but the default is readable by this viewer (e.g. an
|
||||
// anon hitting a deployment whose only project is unlisted-but-default) —
|
||||
// land there. §22 S5: when the default is gated to this viewer (C3.2) or
|
||||
// absent (C3.1, no projects), we do NOT bounce into a 404 — we fall through
|
||||
// to the directory's role-aware empty state below.
|
||||
return <Navigate to={`/p/${defaultProjectId}/`} replace />
|
||||
}
|
||||
return <Directory />
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// §22.8 — the request-to-join API client builds scope-keyed URLs and methods.
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { joinTarget, requestJoin, acceptJoinRequest, declineJoinRequest } from './api.js'
|
||||
|
||||
function mockFetch() {
|
||||
const fn = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ ok: true }),
|
||||
}))
|
||||
global.fetch = fn
|
||||
return fn
|
||||
}
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
describe('join-request api URLs', () => {
|
||||
it('joinTarget GETs the scope join-target', async () => {
|
||||
const f = mockFetch()
|
||||
await joinTarget('collection', 'features')
|
||||
expect(f).toHaveBeenCalledWith('/api/scopes/collection/features/join-target')
|
||||
})
|
||||
|
||||
it('requestJoin POSTs the desired role + message', async () => {
|
||||
const f = mockFetch()
|
||||
await requestJoin('project', 'ohm', { role: 'contributor', message: 'hi' })
|
||||
expect(f.mock.calls[0][0]).toBe('/api/scopes/project/ohm/join-requests')
|
||||
const opts = f.mock.calls[0][1]
|
||||
expect(opts.method).toBe('POST')
|
||||
expect(JSON.parse(opts.body)).toEqual({ role: 'contributor', message: 'hi' })
|
||||
})
|
||||
|
||||
it('acceptJoinRequest POSTs the accept route with an optional role override', async () => {
|
||||
const f = mockFetch()
|
||||
await acceptJoinRequest('collection', 'features', 7, 'contributor')
|
||||
expect(f.mock.calls[0][0]).toBe('/api/scopes/collection/features/join-requests/7/accept')
|
||||
expect(JSON.parse(f.mock.calls[0][1].body)).toEqual({ role: 'contributor' })
|
||||
})
|
||||
|
||||
it('declineJoinRequest POSTs the decline route', async () => {
|
||||
const f = mockFetch()
|
||||
await declineJoinRequest('collection', 'features', 7)
|
||||
expect(f.mock.calls[0][0]).toBe('/api/scopes/collection/features/join-requests/7/decline')
|
||||
expect(f.mock.calls[0][1].method).toBe('POST')
|
||||
})
|
||||
})
|
||||
+141
-5
@@ -182,14 +182,48 @@ export async function getProject(projectId) {
|
||||
return jsonOrThrow(await fetch(`/api/projects/${projectId}`))
|
||||
}
|
||||
|
||||
// §22 S5: create-project (global Owner). The backend provisions a Gitea content
|
||||
// repo, commits the project to projects.yaml, re-mirrors the registry, and
|
||||
// returns the new project { id, name, type, visibility }.
|
||||
export async function createProject({ projectId, name, type, visibility, contentRepo }) {
|
||||
const res = await fetch('/api/projects', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
project_id: projectId,
|
||||
name,
|
||||
type,
|
||||
visibility: visibility || null,
|
||||
content_repo: contentRepo || null,
|
||||
}),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// §22.4 (Plan B) / §22 S2: per-collection serving. With a projectId + a
|
||||
// collectionId, read the collection-scoped routes; with only a projectId, the
|
||||
// project default-collection compat path; with neither, the unscoped path.
|
||||
export async function listRFCs(projectId, collectionId) {
|
||||
if (projectId && collectionId) {
|
||||
return jsonOrThrow(await fetch(`/api/projects/${projectId}/collections/${collectionId}/rfcs`))
|
||||
// §22.4a SLICE-3: faceted catalog. `opts.selections` is { field: string[] }
|
||||
// (each non-empty array becomes repeated query params, OR within the field);
|
||||
// `opts.malformed` toggles the malformed-only filter. Returns the full payload
|
||||
// { items, facets } — callers read both. With no selections it behaves as the
|
||||
// pre-SLICE-3 list (and a no-fields collection returns facets: {}). Facets are
|
||||
// served only on the project/collection-scoped paths, not the unscoped one.
|
||||
export async function listRFCs(projectId, collectionId, opts = {}) {
|
||||
const { selections = {}, malformed = false } = opts
|
||||
const qs = new URLSearchParams()
|
||||
for (const [field, values] of Object.entries(selections)) {
|
||||
for (const v of values) qs.append(field, v)
|
||||
}
|
||||
const url = projectId ? `/api/projects/${projectId}/rfcs` : '/api/rfcs'
|
||||
if (malformed) qs.set('malformed', 'true')
|
||||
const query = qs.toString()
|
||||
let base
|
||||
if (projectId && collectionId) {
|
||||
base = `/api/projects/${projectId}/collections/${collectionId}/rfcs`
|
||||
} else {
|
||||
base = projectId ? `/api/projects/${projectId}/rfcs` : '/api/rfcs'
|
||||
}
|
||||
const url = query ? `${base}?${query}` : base
|
||||
return jsonOrThrow(await fetch(url))
|
||||
}
|
||||
|
||||
@@ -204,11 +238,45 @@ export async function getRFC(projectId, slug, collectionId) {
|
||||
return jsonOrThrow(await fetch(`/api/projects/${projectId}/rfcs/${slug}`))
|
||||
}
|
||||
|
||||
// §22 S2: the collections of a project (for the /p/<project>/ directory).
|
||||
// §22 S2: the collections of a project (for the /p/<project>/ directory). The
|
||||
// response also carries a `viewer` block (§22 S4 capability flags:
|
||||
// can_create_collection, can_invite, role) driving the role-aware directory.
|
||||
export async function listCollections(projectId) {
|
||||
return jsonOrThrow(await fetch(`/api/projects/${projectId}/collections`))
|
||||
}
|
||||
|
||||
// §22 S4: one collection's settings + the viewer's collection-level
|
||||
// capabilities (viewer.can_contribute / can_invite / role) — drives the
|
||||
// propose-first empty state and the collection invite control.
|
||||
export async function getCollection(projectId, collectionId) {
|
||||
return jsonOrThrow(await fetch(`/api/projects/${projectId}/collections/${collectionId}`))
|
||||
}
|
||||
|
||||
// §22 S4 (C.2): the scope-role membership surface. An Owner grants
|
||||
// {owner, contributor} at the project, or at one collection (collectionId set),
|
||||
// to an existing account by email; the backend writes the membership row and
|
||||
// §15-notifies the grantee.
|
||||
export async function listScopeMembers(projectId) {
|
||||
return jsonOrThrow(await fetch(`/api/projects/${projectId}/members`))
|
||||
}
|
||||
|
||||
export async function grantScopeMember(projectId, { email, role, collectionId }) {
|
||||
const res = await fetch(`/api/projects/${projectId}/members`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, role, collection_id: collectionId || null }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function revokeScopeMember(projectId, userId, collectionId) {
|
||||
const qs = collectionId ? `?collection_id=${encodeURIComponent(collectionId)}` : ''
|
||||
const res = await fetch(`/api/projects/${projectId}/members/${userId}${qs}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// §22 S2: create-collection (deployment owner/admin). The backend commits a
|
||||
// .collection.yaml and re-mirrors the registry, returning the new collection.
|
||||
export async function createCollection(projectId, { collectionId, type, name, visibility, initialState }) {
|
||||
@@ -315,6 +383,46 @@ export async function declineContributionRequest(slug, requestId) {
|
||||
))
|
||||
}
|
||||
|
||||
// §22.8: request-to-join a scope + the cross-collection inbox.
|
||||
// `joinTarget` feeds the request modal (scope name, the viewer's eligibility);
|
||||
// `requestJoin` submits the ask (desired role + optional message); accept/decline
|
||||
// are the scope Owner's inbox actions (accept writes the membership row).
|
||||
export async function joinTarget(scopeType, scopeId) {
|
||||
return jsonOrThrow(await fetch(
|
||||
`/api/scopes/${scopeType}/${encodeURIComponent(scopeId)}/join-target`,
|
||||
))
|
||||
}
|
||||
|
||||
export async function requestJoin(scopeType, scopeId, { role, message }) {
|
||||
const res = await fetch(
|
||||
`/api/scopes/${scopeType}/${encodeURIComponent(scopeId)}/join-requests`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role, message: message || null }),
|
||||
},
|
||||
)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function acceptJoinRequest(scopeType, scopeId, requestId, role) {
|
||||
return jsonOrThrow(await fetch(
|
||||
`/api/scopes/${scopeType}/${encodeURIComponent(scopeId)}/join-requests/${requestId}/accept`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role: role || null }),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
export async function declineJoinRequest(scopeType, scopeId, requestId) {
|
||||
return jsonOrThrow(await fetch(
|
||||
`/api/scopes/${scopeType}/${encodeURIComponent(scopeId)}/join-requests/${requestId}/decline`,
|
||||
{ method: 'POST' },
|
||||
))
|
||||
}
|
||||
|
||||
export async function mergeProposal(prNumber) {
|
||||
const res = await fetch(`/api/proposals/${prNumber}/merge`, { method: 'POST' })
|
||||
return jsonOrThrow(res)
|
||||
@@ -574,6 +682,34 @@ export async function editMetadata(slug, { title, tags, prDescription }) {
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// §22.4a SLICE-4: edit one entry's schema-defined metadata — a direct commit
|
||||
// to its `<slug>.meta.yaml` sidecar (contributor+ gated server-side).
|
||||
export async function saveEntryMeta(projectId, collectionId, slug, values) {
|
||||
const res = await fetch(
|
||||
`/api/projects/${projectId}/collections/${collectionId}/rfcs/${slug}/meta`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ values }),
|
||||
},
|
||||
)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// §22.4a SLICE-5 (PUC-2): apply one field op (set | add | remove) to many
|
||||
// entries at once — one commit server-side; returns { applied, rejected }.
|
||||
export async function bulkEntryMeta(projectId, collectionId, { slugs, op, field, value }) {
|
||||
const res = await fetch(
|
||||
`/api/projects/${projectId}/collections/${collectionId}/meta/bulk`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slugs, op, field, value }),
|
||||
},
|
||||
)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// ── Slice 5: §13 graduation + §13.1 claim ────────────────────────────────
|
||||
|
||||
export async function claimOwnership(slug) {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
// §22.4a SLICE-5 (PUC-2, UX §5.3): the sticky bulk action bar shown when ≥1
|
||||
// catalog row is selected. Driven by the collection `fields:` schema — one
|
||||
// "Set <field>" control per enum field, and an Add/Remove tag control per
|
||||
// tags field. Each gesture calls onApply({ op, field, value }); the parent
|
||||
// (Catalog) sends one bulk request and re-fetches.
|
||||
|
||||
const labelFor = (name, def) =>
|
||||
def?.label || name.charAt(0).toUpperCase() + name.slice(1)
|
||||
|
||||
export default function BulkActionBar({ fields, count, onApply, onClear }) {
|
||||
const [tagValue, setTagValue] = useState('')
|
||||
const entries = Object.entries(fields || {})
|
||||
const tagField = entries.find(([, d]) => d.type === 'tags')
|
||||
|
||||
return (
|
||||
<div className="bulk-action-bar">
|
||||
<span className="bulk-count">{count} selected</span>
|
||||
{entries
|
||||
.filter(([, d]) => d.type === 'enum')
|
||||
.map(([name, def]) => (
|
||||
<label key={name} className="bulk-set">
|
||||
<span>Set {labelFor(name, def)}</span>
|
||||
<select
|
||||
aria-label={`Set ${labelFor(name, def)}`}
|
||||
value=""
|
||||
onChange={e => {
|
||||
if (e.target.value) onApply({ op: 'set', field: name, value: e.target.value })
|
||||
}}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{(def.values || []).map(v => <option key={v} value={v}>{v}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
))}
|
||||
{tagField && (
|
||||
<div className="bulk-tags">
|
||||
<input
|
||||
placeholder="tag…"
|
||||
value={tagValue}
|
||||
onChange={e => setTagValue(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!tagValue.trim()}
|
||||
onClick={() => { onApply({ op: 'add', field: tagField[0], value: tagValue.trim() }); setTagValue('') }}
|
||||
>
|
||||
Add tag
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!tagValue.trim()}
|
||||
onClick={() => { onApply({ op: 'remove', field: tagField[0], value: tagValue.trim() }); setTagValue('') }}
|
||||
>
|
||||
Remove tag
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button type="button" className="bulk-clear" onClick={onClear}>Clear</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import BulkActionBar from './BulkActionBar.jsx'
|
||||
|
||||
const FIELDS = {
|
||||
priority: { type: 'enum', values: ['P0', 'P1', 'P2'], label: 'Priority' },
|
||||
tags: { type: 'tags', label: 'Tags' },
|
||||
}
|
||||
|
||||
describe('BulkActionBar', () => {
|
||||
it('shows the selected count', () => {
|
||||
render(<BulkActionBar fields={FIELDS} count={3} onApply={() => {}} onClear={() => {}} />)
|
||||
expect(screen.getByText(/3 selected/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies a set op when an enum value is chosen', () => {
|
||||
const onApply = vi.fn()
|
||||
render(<BulkActionBar fields={FIELDS} count={2} onApply={onApply} onClear={() => {}} />)
|
||||
fireEvent.change(screen.getByLabelText(/set priority/i), { target: { value: 'P0' } })
|
||||
expect(onApply).toHaveBeenCalledWith({ op: 'set', field: 'priority', value: 'P0' })
|
||||
})
|
||||
|
||||
it('applies an add-tag op', () => {
|
||||
const onApply = vi.fn()
|
||||
render(<BulkActionBar fields={FIELDS} count={2} onApply={onApply} onClear={() => {}} />)
|
||||
fireEvent.change(screen.getByPlaceholderText(/tag…/i), { target: { value: 'checkout' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /add tag/i }))
|
||||
expect(onApply).toHaveBeenCalledWith({ op: 'add', field: 'tags', value: 'checkout' })
|
||||
})
|
||||
|
||||
it('applies a remove-tag op', () => {
|
||||
const onApply = vi.fn()
|
||||
render(<BulkActionBar fields={FIELDS} count={2} onApply={onApply} onClear={() => {}} />)
|
||||
fireEvent.change(screen.getByPlaceholderText(/tag…/i), { target: { value: 'checkout' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /remove tag/i }))
|
||||
expect(onApply).toHaveBeenCalledWith({ op: 'remove', field: 'tags', value: 'checkout' })
|
||||
})
|
||||
|
||||
it('calls onClear', () => {
|
||||
const onClear = vi.fn()
|
||||
render(<BulkActionBar fields={FIELDS} count={2} onApply={() => {}} onClear={onClear} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /clear/i }))
|
||||
expect(onClear).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -8,8 +8,12 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { listRFCs, listProposals } from '../api'
|
||||
import { listRFCs, listProposals, getCollection, bulkEntryMeta } from '../api'
|
||||
import { entryPath, proposalPath, useProjectId, useCollectionId } from '../lib/entryPaths'
|
||||
import JoinRequestModal from './JoinRequestModal.jsx'
|
||||
import FacetGroups from './FacetGroups.jsx'
|
||||
import BulkActionBar from './BulkActionBar.jsx'
|
||||
import { showToast } from './ToastHost.jsx'
|
||||
|
||||
const STATE_CHIPS = [
|
||||
{ id: 'super-draft', label: 'Super-draft' },
|
||||
@@ -26,25 +30,97 @@ const SORT_OPTIONS = [
|
||||
export default function Catalog({ viewer, onProposeRFC, version }) {
|
||||
const [rfcs, setRfcs] = useState([])
|
||||
const [proposals, setProposals] = useState([])
|
||||
// §22 S4: the viewer's contribute capability in this collection, driving the
|
||||
// propose-first empty state (C3.5) and the propose control. `null` until the
|
||||
// collection's caps load; we fall back to "any authenticated viewer" so the
|
||||
// common case doesn't flicker, then refine.
|
||||
const [canContribute, setCanContribute] = useState(null)
|
||||
// §22.8: when the viewer can't contribute here but holds no role reaching the
|
||||
// collection, offer "Request to join" in the footer.
|
||||
const [canRequestJoin, setCanRequestJoin] = useState(false)
|
||||
const [joinOpen, setJoinOpen] = useState(false)
|
||||
// §22.4a: the type-driven entry noun ("RFC" | "Spec" | "Feature") for this
|
||||
// collection, read from the API. Defaults to the generic "RFC" until loaded.
|
||||
const [entryNoun, setEntryNoun] = useState('RFC')
|
||||
const [search, setSearch] = useState('')
|
||||
const [sort, setSort] = useState('recent')
|
||||
const [activeChips, setActiveChips] = useState(new Set())
|
||||
const [pendingOpen, setPendingOpen] = useState(true)
|
||||
// §22.4a SLICE-3: faceted left pane. When the collection declares a `fields:`
|
||||
// schema, the catalog renders faceted groups (counts from the server) instead
|
||||
// of the legacy state chips, and re-fetches server-side as selections change.
|
||||
const [fields, setFields] = useState(null) // collection field schema
|
||||
const [facets, setFacets] = useState({}) // { field: { value: count } }
|
||||
const [selections, setSelections] = useState({}) // { field: Set<string> }
|
||||
const [malformedOnly, setMalformedOnly] = useState(false)
|
||||
// §22.4a SLICE-5: multi-select for the bulk action bar (PUC-2). A Set of
|
||||
// selected slugs; the sticky bar appears when ≥1 is selected (faceted +
|
||||
// contributor only). Cleared on collection switch and after a bulk apply.
|
||||
const [selected, setSelected] = useState(() => new Set())
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { slug, prNumber } = useParams()
|
||||
const pid = useProjectId()
|
||||
// §22 S2: the catalog is scoped to the active collection (the `/c/:cid/`
|
||||
// route segment, else the project's default collection).
|
||||
const cid = useCollectionId()
|
||||
|
||||
// Collection-level data (proposals, caps, field schema) loads once per
|
||||
// collection — independent of the facet selections, which only re-fetch the
|
||||
// entry list. Switching collections clears any active facet selections.
|
||||
useEffect(() => {
|
||||
listRFCs(pid, cid).then(d => setRfcs(d.items)).catch(() => setRfcs([]))
|
||||
listProposals(pid).then(d => setProposals(d.items)).catch(() => setProposals([]))
|
||||
setCanContribute(null)
|
||||
setCanRequestJoin(false)
|
||||
setSelections({})
|
||||
setMalformedOnly(false)
|
||||
setSelected(new Set())
|
||||
getCollection(pid, cid)
|
||||
.then(c => {
|
||||
setCanContribute(!!c?.viewer?.can_contribute)
|
||||
setCanRequestJoin(!!c?.viewer?.can_request_join)
|
||||
setEntryNoun(c?.entry_noun || 'RFC')
|
||||
setFields(c?.fields || null)
|
||||
})
|
||||
.catch(() => { setCanContribute(false); setFields(null) })
|
||||
}, [version, pid, cid])
|
||||
|
||||
// §22.4a SLICE-3: the entry list re-fetches server-side whenever the facet
|
||||
// selections or the malformed toggle change (in legacy mode selections stay
|
||||
// empty, so this fires once per collection like before).
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
const selObj = Object.fromEntries(
|
||||
Object.entries(selections).map(([f, set]) => [f, [...set]])
|
||||
)
|
||||
listRFCs(pid, cid, { selections: selObj, malformed: malformedOnly })
|
||||
.then(d => {
|
||||
setRfcs(d.items); setFacets(d.facets || {})
|
||||
// §22.4a SLICE-5: drop any bulk selections that the new (filtered)
|
||||
// list no longer contains, so the bar can't act on hidden rows.
|
||||
const visible = new Set(d.items.map(it => it.slug))
|
||||
setSelected(prev => {
|
||||
const kept = [...prev].filter(s => visible.has(s))
|
||||
return kept.length === prev.size ? prev : new Set(kept)
|
||||
})
|
||||
})
|
||||
.catch(() => { setRfcs([]); setFacets({}) })
|
||||
.finally(() => setLoading(false))
|
||||
}, [version, pid, cid, selections, malformedOnly])
|
||||
|
||||
// While caps load, fall back to "any authenticated viewer" so the propose
|
||||
// affordance on the common (default-collection) case doesn't flash off.
|
||||
const mayPropose = canContribute === null ? !!viewer : canContribute
|
||||
|
||||
// §22.4a SLICE-3: faceted mode when the collection declares a non-empty
|
||||
// `fields:` schema; otherwise the legacy state-chip catalog (INV-5).
|
||||
const faceted = !!fields && Object.keys(fields).length > 0
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const needle = search.trim().toLowerCase()
|
||||
let items = rfcs.filter(r => {
|
||||
if (activeChips.size > 0 && !activeChips.has(r.state)) return false
|
||||
// In faceted mode the server already filtered by state; the legacy chips
|
||||
// narrow client-side only when there's no schema.
|
||||
if (!faceted && activeChips.size > 0 && !activeChips.has(r.state)) return false
|
||||
if (!needle) return true
|
||||
const hay = [r.title, r.slug, r.id || ''].join(' ').toLowerCase()
|
||||
return hay.includes(needle)
|
||||
@@ -59,7 +135,7 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
||||
return (b.last_active_at || '').localeCompare(a.last_active_at || '')
|
||||
})
|
||||
return items
|
||||
}, [rfcs, search, sort, activeChips])
|
||||
}, [rfcs, search, sort, activeChips, faceted])
|
||||
|
||||
function toggleChip(id) {
|
||||
const next = new Set(activeChips)
|
||||
@@ -67,6 +143,53 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
||||
setActiveChips(next)
|
||||
}
|
||||
|
||||
// §22.4a SLICE-3 facet selection handlers (faceted mode).
|
||||
function toggleFacet(field, value) {
|
||||
setSelections(prev => {
|
||||
const next = { ...prev }
|
||||
const set = new Set(next[field] || [])
|
||||
set.has(value) ? set.delete(value) : set.add(value)
|
||||
if (set.size === 0) delete next[field]
|
||||
else next[field] = set
|
||||
return next
|
||||
})
|
||||
}
|
||||
function clearFilters() { setSelections({}); setMalformedOnly(false) }
|
||||
const hasActiveFilters = Object.keys(selections).length > 0 || malformedOnly
|
||||
|
||||
// §22.4a SLICE-5: row selection + bulk apply (PUC-2).
|
||||
function toggleSelect(rowSlug) {
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev)
|
||||
next.has(rowSlug) ? next.delete(rowSlug) : next.add(rowSlug)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function applyBulk({ op, field, value }) {
|
||||
const slugs = [...selected]
|
||||
if (slugs.length === 0) return
|
||||
try {
|
||||
const res = await bulkEntryMeta(pid, cid, { slugs, op, field, value })
|
||||
const nApplied = res.applied?.length || 0
|
||||
const nRejected = res.rejected?.length || 0
|
||||
showToast({
|
||||
summary: nRejected
|
||||
? `${nApplied} updated, ${nRejected} skipped`
|
||||
: `${nApplied} updated`,
|
||||
category: nRejected ? 'warning' : 'success',
|
||||
})
|
||||
setSelected(new Set())
|
||||
// Re-fetch the list so the new values + facet counts reflect the change.
|
||||
const selObj = Object.fromEntries(
|
||||
Object.entries(selections).map(([f, set]) => [f, [...set]]))
|
||||
const d = await listRFCs(pid, cid, { selections: selObj, malformed: malformedOnly })
|
||||
setRfcs(d.items); setFacets(d.facets || {})
|
||||
} catch (e) {
|
||||
showToast({ summary: e.message || 'Bulk update failed', category: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="catalog">
|
||||
<div className="catalog-search">
|
||||
@@ -82,30 +205,70 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
||||
{SORT_OPTIONS.map(o => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="catalog-chips">
|
||||
{STATE_CHIPS.map(chip => (
|
||||
<button
|
||||
key={chip.id}
|
||||
className={`chip ${activeChips.has(chip.id) ? 'active' : ''}`}
|
||||
onClick={() => toggleChip(chip.id)}
|
||||
>
|
||||
{chip.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{faceted ? (
|
||||
<FacetGroups
|
||||
facets={facets}
|
||||
fields={fields}
|
||||
selections={selections}
|
||||
onToggle={toggleFacet}
|
||||
malformedOnly={malformedOnly}
|
||||
onToggleMalformed={() => setMalformedOnly(m => !m)}
|
||||
onClear={clearFilters}
|
||||
hasActiveFilters={hasActiveFilters}
|
||||
/>
|
||||
) : (
|
||||
<div className="catalog-chips">
|
||||
{STATE_CHIPS.map(chip => (
|
||||
<button
|
||||
key={chip.id}
|
||||
className={`chip ${activeChips.has(chip.id) ? 'active' : ''}`}
|
||||
onClick={() => toggleChip(chip.id)}
|
||||
>
|
||||
{chip.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{faceted && canContribute && selected.size > 0 && (
|
||||
<BulkActionBar
|
||||
fields={fields}
|
||||
count={selected.size}
|
||||
onApply={applyBulk}
|
||||
onClear={() => setSelected(new Set())}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="catalog-list">
|
||||
{filtered.length === 0 ? (
|
||||
<div style={{ padding: '24px 14px', color: '#999', fontSize: 13 }}>
|
||||
{rfcs.length === 0
|
||||
? (viewer ? 'No RFCs in the catalog yet. Propose one below.' : 'No RFCs in the catalog yet.')
|
||||
{loading
|
||||
? 'Loading…'
|
||||
// §22.4a SLICE-3: faceted mode with active filters → an
|
||||
// explicitly-clearable "no entries match" state (§5.1).
|
||||
: faceted && hasActiveFilters ? (
|
||||
<>
|
||||
No entries match.{' '}
|
||||
<button className="facet-clear" onClick={clearFilters}>Clear filters</button>
|
||||
</>
|
||||
)
|
||||
: rfcs.length === 0
|
||||
// C3.5: a contributor sees a propose-first call to action; a
|
||||
// granted viewer without contribute rights sees a bare empty
|
||||
// state; an anonymous reader sees the read-only note (the footer
|
||||
// carries the sign-in prompt).
|
||||
? (mayPropose
|
||||
? 'No entries yet. Propose the first entry below.'
|
||||
: 'No entries in the catalog yet.')
|
||||
: 'No matches.'}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map(r => {
|
||||
const isActive = slug === r.slug
|
||||
const isSuper = r.state === 'super-draft'
|
||||
return (
|
||||
// §22.4a SLICE-5: selectable rows in faceted mode for contributors.
|
||||
const selectable = faceted && canContribute
|
||||
const row = (
|
||||
<Link
|
||||
key={r.slug}
|
||||
to={entryPath(pid, r.slug, cid)}
|
||||
@@ -115,6 +278,9 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
||||
<span className={`row-id ${isSuper ? 'super' : ''}`}>
|
||||
{isSuper ? 'super-draft' : (r.id || '—')}
|
||||
</span>
|
||||
{r.metadata_malformed && (
|
||||
<span className="row-malformed" title="Metadata fails this collection's schema">⚠ malformed</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="row-title">{r.title}</span>
|
||||
{r.tags.length > 0 && (
|
||||
@@ -122,6 +288,19 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
if (!selectable) return row
|
||||
return (
|
||||
<div key={r.slug} className="catalog-row-wrap selectable">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="row-select"
|
||||
aria-label={`select ${r.title}`}
|
||||
checked={selected.has(r.slug)}
|
||||
onChange={() => toggleSelect(r.slug)}
|
||||
/>
|
||||
{row}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
@@ -154,13 +333,30 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
||||
|
||||
<div className="catalog-footer">
|
||||
{viewer ? (
|
||||
<button className="btn-propose" onClick={onProposeRFC}>+ Propose New RFC</button>
|
||||
// §22 S4: the propose control is offered only when the viewer may
|
||||
// contribute to *this* collection (the scope-role gate); a granted
|
||||
// viewer with no contribute right in this collection sees nothing.
|
||||
mayPropose ? (
|
||||
<button className="btn-propose" onClick={onProposeRFC}>+ Propose New {entryNoun}</button>
|
||||
) : (
|
||||
// §22.8: signed in but no contribute right here — offer to join.
|
||||
canRequestJoin && (
|
||||
<button className="btn-propose" onClick={() => setJoinOpen(true)}>Request to join</button>
|
||||
)
|
||||
)
|
||||
) : (
|
||||
<a className="btn-propose" href="/auth/login" title="Private beta — only invited emails can propose">
|
||||
Sign in to propose <span className="beta-chip">Beta</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{joinOpen && (
|
||||
<JoinRequestModal
|
||||
scopeType="collection"
|
||||
scopeId={cid}
|
||||
onClose={() => setJoinOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
|
||||
// §22.4a SLICE-3 — the faceted catalog pane (PUC-3). Mock the api so
|
||||
// getCollection drives the field schema and listRFCs returns { items, facets }.
|
||||
const listRFCs = vi.fn()
|
||||
const getCollection = vi.fn()
|
||||
const bulkEntryMeta = vi.fn()
|
||||
vi.mock('../api', () => ({
|
||||
listRFCs: (...a) => listRFCs(...a),
|
||||
listProposals: () => Promise.resolve({ items: [] }),
|
||||
getCollection: (...a) => getCollection(...a),
|
||||
bulkEntryMeta: (...a) => bulkEntryMeta(...a),
|
||||
}))
|
||||
vi.mock('./ToastHost.jsx', () => ({ showToast: vi.fn() }))
|
||||
vi.mock('../lib/entryPaths', () => ({
|
||||
entryPath: () => '/x', proposalPath: () => '/p',
|
||||
useProjectId: () => 'ohm', useCollectionId: () => 'default',
|
||||
}))
|
||||
import Catalog from './Catalog.jsx'
|
||||
|
||||
const renderCatalog = () =>
|
||||
render(<MemoryRouter><Catalog viewer={null} onProposeRFC={() => {}} version={0} /></MemoryRouter>)
|
||||
|
||||
describe('Catalog faceted pane', () => {
|
||||
beforeEach(() => { listRFCs.mockReset(); getCollection.mockReset(); bulkEntryMeta.mockReset() })
|
||||
|
||||
it('renders facet groups with counts when the collection declares fields', async () => {
|
||||
getCollection.mockResolvedValue({
|
||||
fields: { priority: { type: 'enum', values: ['P0', 'P1'] } },
|
||||
viewer: { can_contribute: false }, entry_noun: 'RFC',
|
||||
})
|
||||
listRFCs.mockResolvedValue({
|
||||
items: [{ slug: 'a', title: 'A', state: 'active', tags: [], starred_by_me: false }],
|
||||
facets: { priority: { P0: 3, P1: 1 }, state: { active: 4 } },
|
||||
})
|
||||
renderCatalog()
|
||||
expect(await screen.findByText('P0')).toBeInTheDocument()
|
||||
expect(screen.getByText('3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('re-fetches with the selection when a facet checkbox is toggled', async () => {
|
||||
getCollection.mockResolvedValue({
|
||||
fields: { priority: { type: 'enum', values: ['P0'] } },
|
||||
viewer: {}, entry_noun: 'RFC',
|
||||
})
|
||||
listRFCs.mockResolvedValue({ items: [], facets: { priority: { P0: 2 }, state: {} } })
|
||||
renderCatalog()
|
||||
const box = await screen.findByText('P0')
|
||||
fireEvent.click(box.closest('label').querySelector('input'))
|
||||
await waitFor(() => {
|
||||
const last = listRFCs.mock.calls.at(-1)
|
||||
expect(last[2].selections.priority).toContain('P0')
|
||||
})
|
||||
})
|
||||
|
||||
it('reveals the bulk action bar when a row is selected (faceted + contributor)', async () => {
|
||||
getCollection.mockResolvedValue({
|
||||
fields: { priority: { type: 'enum', values: ['P0', 'P1'] } },
|
||||
viewer: { can_contribute: true }, entry_noun: 'RFC',
|
||||
})
|
||||
listRFCs.mockResolvedValue({
|
||||
items: [{ slug: 'a', title: 'A', state: 'active', tags: [], starred_by_me: false }],
|
||||
facets: { priority: { P0: 1 }, state: { active: 1 } },
|
||||
})
|
||||
renderCatalog()
|
||||
const checkbox = await screen.findByLabelText(/select A/i)
|
||||
fireEvent.click(checkbox)
|
||||
expect(await screen.findByText(/1 selected/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('sends a bulk request and re-fetches when a set op is applied', async () => {
|
||||
getCollection.mockResolvedValue({
|
||||
fields: { priority: { type: 'enum', values: ['P0', 'P1'] } },
|
||||
viewer: { can_contribute: true }, entry_noun: 'RFC',
|
||||
})
|
||||
listRFCs.mockResolvedValue({
|
||||
items: [{ slug: 'a', title: 'A', state: 'active', tags: [], starred_by_me: false }],
|
||||
facets: { priority: { P0: 1 }, state: { active: 1 } },
|
||||
})
|
||||
bulkEntryMeta.mockResolvedValue({ applied: ['a'], rejected: [], committed: true })
|
||||
renderCatalog()
|
||||
fireEvent.click(await screen.findByLabelText(/select A/i))
|
||||
fireEvent.change(await screen.findByLabelText(/set priority/i), { target: { value: 'P1' } })
|
||||
await waitFor(() => {
|
||||
expect(bulkEntryMeta).toHaveBeenCalledWith('ohm', 'default',
|
||||
{ slugs: ['a'], op: 'set', field: 'priority', value: 'P1' })
|
||||
})
|
||||
})
|
||||
|
||||
it('drops a selection when a filter change hides the row', async () => {
|
||||
getCollection.mockResolvedValue({
|
||||
fields: { priority: { type: 'enum', values: ['P0', 'P1'] } },
|
||||
viewer: { can_contribute: true }, entry_noun: 'RFC',
|
||||
})
|
||||
// Content-driven: unfiltered → row "a"; once the P0 facet is selected the
|
||||
// server response omits "a" (drives the prune). Robust to extra mount-time
|
||||
// fetches the collection effect triggers.
|
||||
listRFCs.mockImplementation((p, c, opts) => Promise.resolve({
|
||||
items: opts?.selections?.priority?.includes('P0')
|
||||
? [{ slug: 'b', title: 'B', state: 'active', tags: [], starred_by_me: false }]
|
||||
: [{ slug: 'a', title: 'A', state: 'active', tags: [], starred_by_me: false }],
|
||||
facets: { priority: { P0: 1 }, state: { active: 1 } },
|
||||
}))
|
||||
const { container } = renderCatalog()
|
||||
fireEvent.click(await screen.findByLabelText(/select A/i))
|
||||
expect(await screen.findByText(/1 selected/i)).toBeInTheDocument()
|
||||
// Toggle the P0 facet checkbox → re-fetch returns a list without "a".
|
||||
fireEvent.click(container.querySelector('.facet-group input[type="checkbox"]'))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/1 selected/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('does not offer row selection for a non-contributor', async () => {
|
||||
getCollection.mockResolvedValue({
|
||||
fields: { priority: { type: 'enum', values: ['P0'] } },
|
||||
viewer: { can_contribute: false }, entry_noun: 'RFC',
|
||||
})
|
||||
listRFCs.mockResolvedValue({
|
||||
items: [{ slug: 'a', title: 'A', state: 'active', tags: [], starred_by_me: false }],
|
||||
facets: { priority: { P0: 1 }, state: { active: 1 } },
|
||||
})
|
||||
renderCatalog()
|
||||
await screen.findByText('A')
|
||||
expect(screen.queryByLabelText(/select A/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders legacy state chips when the collection declares no fields', async () => {
|
||||
getCollection.mockResolvedValue({ fields: null, viewer: {}, entry_noun: 'RFC' })
|
||||
listRFCs.mockResolvedValue({ items: [], facets: {} })
|
||||
renderCatalog()
|
||||
expect(await screen.findByText('Super-draft')).toBeInTheDocument()
|
||||
expect(screen.getByText('Active')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -2,23 +2,41 @@
|
||||
// project's caller-visible collections as cards linking into each collection's
|
||||
// `/p/<project>/c/<collection>/` home. When exactly one collection is visible
|
||||
// the directory is skipped and we redirect straight into it (the S1 C3.7/C3.8
|
||||
// single-collection UX, preserved). The role-keyed "Create your first
|
||||
// collection" empty state is S4; S2 shows a minimal note when there are none.
|
||||
// single-collection UX, preserved).
|
||||
//
|
||||
// §22 S4 — role-aware empty states + owner controls. The list response carries
|
||||
// a `viewer` capability block; the directory reads it to render:
|
||||
// * C3.3: a project Owner sees a "Create your first collection" CTA (and a
|
||||
// "New collection" control when the directory is non-empty), opening the
|
||||
// create-collection modal (choose a type + id + visibility).
|
||||
// * C3.4: a contributor without create rights sees the empty directory with
|
||||
// no create action.
|
||||
// * C.2: an Owner with membership-management reach sees a "Members" control
|
||||
// opening the scope-role invitation modal.
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, Navigate } from 'react-router-dom'
|
||||
import { listCollections } from '../api'
|
||||
import { collectionHome } from '../lib/entryPaths'
|
||||
import { entryNoun } from './ProjectLayout.jsx'
|
||||
import CreateCollectionModal from './CreateCollectionModal.jsx'
|
||||
import ScopeMembersModal from './ScopeMembersModal.jsx'
|
||||
import JoinRequestModal from './JoinRequestModal.jsx'
|
||||
|
||||
export default function CollectionDirectory({ projectId }) {
|
||||
const [cols, setCols] = useState(null)
|
||||
const [viewer, setViewer] = useState(null)
|
||||
const [version, setVersion] = useState(0)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [membersOpen, setMembersOpen] = useState(false)
|
||||
const [joinOpen, setJoinOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
listCollections(projectId)
|
||||
.then(d => { if (live) setCols(d.items) })
|
||||
.catch(() => { if (live) setCols([]) })
|
||||
.then(d => { if (live) { setCols(d.items); setViewer(d.viewer || null) } })
|
||||
.catch(() => { if (live) { setCols([]); setViewer(null) } })
|
||||
return () => { live = false }
|
||||
}, [projectId])
|
||||
}, [projectId, version])
|
||||
|
||||
if (cols === null) {
|
||||
return <main className="chrome-pane"><div className="boot">Loading…</div></main>
|
||||
@@ -27,12 +45,40 @@ export default function CollectionDirectory({ projectId }) {
|
||||
if (cols.length === 1) {
|
||||
return <Navigate to={collectionHome(projectId, cols[0].id)} replace />
|
||||
}
|
||||
|
||||
const canCreate = !!viewer?.can_create_collection
|
||||
const canInvite = !!viewer?.can_invite
|
||||
const canRequestJoin = !!viewer?.can_request_join
|
||||
|
||||
return (
|
||||
<main className="chrome-pane">
|
||||
<div className="directory">
|
||||
<h1>Collections</h1>
|
||||
<div className="directory-head">
|
||||
<h1>Collections</h1>
|
||||
<div className="directory-actions">
|
||||
{canInvite && (
|
||||
<button className="btn-link" onClick={() => setMembersOpen(true)}>Members</button>
|
||||
)}
|
||||
{canRequestJoin && (
|
||||
<button className="btn-link" onClick={() => setJoinOpen(true)}>Request to join</button>
|
||||
)}
|
||||
{canCreate && cols.length > 0 && (
|
||||
<button className="btn-primary" onClick={() => setCreateOpen(true)}>New collection</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{cols.length === 0 ? (
|
||||
<p className="directory-tagline">No collections yet.</p>
|
||||
// C3.3 / C3.4: role-keyed empty state.
|
||||
canCreate ? (
|
||||
<div className="directory-empty">
|
||||
<p className="directory-tagline">No collections yet.</p>
|
||||
<button className="btn-primary" onClick={() => setCreateOpen(true)}>
|
||||
Create your first collection
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="directory-tagline">No collections yet.</p>
|
||||
)
|
||||
) : (
|
||||
<ul className="directory-list">
|
||||
{cols.map(c => (
|
||||
@@ -46,6 +92,28 @@ export default function CollectionDirectory({ projectId }) {
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{createOpen && (
|
||||
<CreateCollectionModal
|
||||
projectId={projectId}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreated={() => { setCreateOpen(false); setVersion(v => v + 1) }}
|
||||
/>
|
||||
)}
|
||||
{membersOpen && (
|
||||
<ScopeMembersModal
|
||||
projectId={projectId}
|
||||
collections={cols}
|
||||
viewer={viewer}
|
||||
onClose={() => setMembersOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{joinOpen && (
|
||||
<JoinRequestModal
|
||||
scopeType="project"
|
||||
scopeId={projectId}
|
||||
onClose={() => setJoinOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,15 +4,20 @@ import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter, Routes, Route } from 'react-router-dom'
|
||||
|
||||
let mockItems = []
|
||||
let mockViewer = null
|
||||
vi.mock('../api', () => ({
|
||||
listCollections: vi.fn(async () => ({ items: mockItems })),
|
||||
listCollections: vi.fn(async () => ({ items: mockItems, viewer: mockViewer })),
|
||||
// JoinRequestModal (rendered behind the affordance) pulls these in.
|
||||
joinTarget: vi.fn(async () => ({ eligible: true, name: 'Ohm', already_requested: false })),
|
||||
requestJoin: vi.fn(async () => ({ status: 'pending' })),
|
||||
}))
|
||||
import CollectionDirectory from './CollectionDirectory.jsx'
|
||||
|
||||
beforeEach(() => { mockItems = [] })
|
||||
beforeEach(() => { mockItems = []; mockViewer = null })
|
||||
|
||||
function renderDir(items) {
|
||||
function renderDir(items, viewer = null) {
|
||||
mockItems = items
|
||||
mockViewer = viewer
|
||||
return render(
|
||||
<MemoryRouter initialEntries={["/p/ohm/"]}>
|
||||
<Routes>
|
||||
@@ -45,4 +50,27 @@ describe('CollectionDirectory', () => {
|
||||
renderDir([])
|
||||
await waitFor(() => expect(screen.getByText('No collections yet.')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('offers "Request to join" when the viewer holds no role (§22.8)', async () => {
|
||||
renderDir(
|
||||
[
|
||||
{ id: 'a', name: 'A', type: 'document' },
|
||||
{ id: 'b', name: 'B', type: 'document' },
|
||||
],
|
||||
{ can_request_join: true },
|
||||
)
|
||||
await waitFor(() => expect(screen.getByText('Request to join')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('hides "Request to join" from a member', async () => {
|
||||
renderDir(
|
||||
[
|
||||
{ id: 'a', name: 'A', type: 'document' },
|
||||
{ id: 'b', name: 'B', type: 'document' },
|
||||
],
|
||||
{ role: 'owner', can_request_join: false },
|
||||
)
|
||||
await waitFor(() => expect(screen.getByText('A')).toBeInTheDocument())
|
||||
expect(screen.queryByText('Request to join')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// CreateCollectionModal.jsx — §22 S2 endpoint, §22 S4 UI. The create-collection
|
||||
// form the project directory's "Create your first collection" / "New
|
||||
// collection" control opens. S2 shipped the POST
|
||||
// /api/projects/:id/collections endpoint but left it UI-less; S4 surfaces it,
|
||||
// gated on the viewer's `can_create_collection` capability.
|
||||
//
|
||||
// The form lets the Owner choose an id (a slug, not "default"), a type, an
|
||||
// optional display name, and an optional visibility (defaulting to the
|
||||
// project's). The backend commits a `.collection.yaml`, re-mirrors the
|
||||
// registry, and returns the new collection; on success the caller refreshes
|
||||
// the directory.
|
||||
|
||||
import { useState } from 'react'
|
||||
import { createCollection } from '../api'
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'document', label: 'Document — prose RFCs' },
|
||||
{ value: 'specification', label: 'Specification' },
|
||||
{ value: 'bdd', label: 'BDD — behaviour scenarios' },
|
||||
]
|
||||
|
||||
const VISIBILITY_OPTIONS = [
|
||||
{ value: '', label: 'Inherit from project' },
|
||||
{ value: 'public', label: 'Public' },
|
||||
{ value: 'unlisted', label: 'Unlisted (link-only)' },
|
||||
{ value: 'gated', label: 'Gated (hidden from the public)' },
|
||||
]
|
||||
|
||||
export default function CreateCollectionModal({ projectId, onClose, onCreated }) {
|
||||
const [collectionId, setCollectionId] = useState('')
|
||||
const [type, setType] = useState('document')
|
||||
const [name, setName] = useState('')
|
||||
const [visibility, setVisibility] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
async function handleCreate(e) {
|
||||
e.preventDefault()
|
||||
const cid = collectionId.trim().toLowerCase()
|
||||
if (!cid) return
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const col = await createCollection(projectId, {
|
||||
collectionId: cid,
|
||||
type,
|
||||
name: name.trim() || null,
|
||||
visibility: visibility || null,
|
||||
})
|
||||
onCreated?.(col)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to create the collection.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="modal" style={{ maxWidth: 560 }}>
|
||||
<div className="modal-header">
|
||||
<h2>New collection</h2>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<form onSubmit={handleCreate} className="invitations-form">
|
||||
<label htmlFor="col-id">Collection id</label>
|
||||
<input
|
||||
id="col-id"
|
||||
value={collectionId}
|
||||
onChange={e => setCollectionId(e.target.value)}
|
||||
placeholder="e.g. model, features"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<label htmlFor="col-type" style={{ marginTop: 10 }}>Type</label>
|
||||
<select id="col-type" value={type} onChange={e => setType(e.target.value)}>
|
||||
{TYPE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<label htmlFor="col-name" style={{ marginTop: 10 }}>Display name (optional)</label>
|
||||
<input
|
||||
id="col-name"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="e.g. The Model"
|
||||
/>
|
||||
<label htmlFor="col-vis" style={{ marginTop: 10 }}>Visibility</label>
|
||||
<select id="col-vis" value={visibility} onChange={e => setVisibility(e.target.value)}>
|
||||
{VISIBILITY_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<div style={{ marginTop: 12, display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<button type="submit" className="btn-primary" disabled={submitting}>
|
||||
{submitting ? 'Creating…' : 'Create collection'}
|
||||
</button>
|
||||
{error && <span style={{ color: '#c33' }}>{error}</span>}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn-link" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// CreateProjectModal.jsx — §22 S5. The create-project form the deployment
|
||||
// directory's "Create your first project" / "New project" control opens, gated
|
||||
// on the viewer's `can_create_project` capability (a global Owner action).
|
||||
//
|
||||
// The form lets the Owner choose a project id (a slug, not "default"), a display
|
||||
// name, the type of its initial (default) collection, a visibility, and an
|
||||
// optional content-repo name (defaulting to `<id>-content`). The backend
|
||||
// provisions the Gitea content repo, commits the project to projects.yaml,
|
||||
// re-mirrors the registry, and returns the new project; on success the caller
|
||||
// refreshes the directory (and the N=1 redirect then lands in the new project).
|
||||
|
||||
import { useState } from 'react'
|
||||
import { createProject } from '../api'
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'document', label: 'Document — prose RFCs' },
|
||||
{ value: 'specification', label: 'Specification' },
|
||||
{ value: 'bdd', label: 'BDD — behaviour scenarios' },
|
||||
]
|
||||
|
||||
const VISIBILITY_OPTIONS = [
|
||||
{ value: 'public', label: 'Public' },
|
||||
{ value: 'unlisted', label: 'Unlisted (link-only)' },
|
||||
{ value: 'gated', label: 'Gated (hidden from the public)' },
|
||||
]
|
||||
|
||||
export default function CreateProjectModal({ onClose, onCreated }) {
|
||||
const [projectId, setProjectId] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [type, setType] = useState('document')
|
||||
const [visibility, setVisibility] = useState('public')
|
||||
const [contentRepo, setContentRepo] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
async function handleCreate(e) {
|
||||
e.preventDefault()
|
||||
const pid = projectId.trim().toLowerCase()
|
||||
if (!pid || !name.trim()) return
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const project = await createProject({
|
||||
projectId: pid,
|
||||
name: name.trim(),
|
||||
type,
|
||||
visibility,
|
||||
contentRepo: contentRepo.trim() || null,
|
||||
})
|
||||
onCreated?.(project)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to create the project.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="modal" style={{ maxWidth: 560 }}>
|
||||
<div className="modal-header">
|
||||
<h2>New project</h2>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<form onSubmit={handleCreate} className="invitations-form">
|
||||
<label htmlFor="proj-id">Project id</label>
|
||||
<input
|
||||
id="proj-id"
|
||||
value={projectId}
|
||||
onChange={e => setProjectId(e.target.value)}
|
||||
placeholder="e.g. ohm, acme"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<label htmlFor="proj-name" style={{ marginTop: 10 }}>Display name</label>
|
||||
<input
|
||||
id="proj-name"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="e.g. Open Human Model"
|
||||
required
|
||||
/>
|
||||
<label htmlFor="proj-type" style={{ marginTop: 10 }}>Initial collection type</label>
|
||||
<select id="proj-type" value={type} onChange={e => setType(e.target.value)}>
|
||||
{TYPE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<label htmlFor="proj-vis" style={{ marginTop: 10 }}>Visibility</label>
|
||||
<select id="proj-vis" value={visibility} onChange={e => setVisibility(e.target.value)}>
|
||||
{VISIBILITY_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<label htmlFor="proj-repo" style={{ marginTop: 10 }}>Content repo (optional)</label>
|
||||
<input
|
||||
id="proj-repo"
|
||||
value={contentRepo}
|
||||
onChange={e => setContentRepo(e.target.value)}
|
||||
placeholder="defaults to <id>-content"
|
||||
/>
|
||||
<div style={{ marginTop: 12, display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<button type="submit" className="btn-primary" disabled={submitting}>
|
||||
{submitting ? 'Creating…' : 'Create project'}
|
||||
</button>
|
||||
{error && <span style={{ color: '#c33' }}>{error}</span>}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn-link" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +1,71 @@
|
||||
// §22.10 (M3) — the deployment directory at `/`. Renders the caller-visible
|
||||
// projects (from /api/deployment) as cards linking into each project's
|
||||
// `/p/<id>/` home. App's DeploymentLanding only mounts this when 2+ projects
|
||||
// are visible; the N=1 case redirects straight into the single project so
|
||||
// OHM's "land in the corpus" UX is preserved.
|
||||
// `/p/<id>/` home. App's DeploymentLanding only mounts this when the N=1
|
||||
// land-in-corpus redirect does not apply (2+ visible projects, or no readable
|
||||
// default) so OHM's "land in the corpus" UX is preserved.
|
||||
//
|
||||
// §22 S5 — role-aware empty states + the global-Owner create-project action.
|
||||
// The deployment payload carries a `viewer` block; the directory reads it to
|
||||
// render:
|
||||
// * C3.1: a global Owner sees a "Create your first project" CTA (and a "New
|
||||
// project" control when the directory is non-empty), opening the
|
||||
// create-project modal (choose id, name, type, visibility).
|
||||
// * C3.2: a non-owner (a granted account with no roles) sees the empty
|
||||
// directory with no create action and a note that nothing is shared yet.
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useDeployment } from '../context/DeploymentProvider'
|
||||
import { entryNoun } from './ProjectLayout.jsx'
|
||||
import CreateProjectModal from './CreateProjectModal.jsx'
|
||||
|
||||
export default function Directory() {
|
||||
const { name, tagline, projects } = useDeployment()
|
||||
const { name, tagline, projects, viewer, refresh } = useDeployment()
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const canCreate = !!viewer?.can_create_project
|
||||
|
||||
return (
|
||||
<main className="chrome-pane">
|
||||
<div className="directory">
|
||||
<h1>{name}</h1>
|
||||
<div className="directory-head">
|
||||
<h1>{name}</h1>
|
||||
<div className="directory-actions">
|
||||
{canCreate && projects.length > 0 && (
|
||||
<button className="btn-primary" onClick={() => setCreateOpen(true)}>New project</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{tagline && <p className="directory-tagline">{tagline}</p>}
|
||||
<ul className="directory-list">
|
||||
{projects.map(p => (
|
||||
<li key={p.id} className="directory-card">
|
||||
<Link to={`/p/${p.id}/`}>
|
||||
<span className="directory-card-name">{p.name}</span>
|
||||
<span className="directory-card-type">{entryNoun(p.type)}s</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{projects.length === 0 ? (
|
||||
// C3.1 / C3.2: role-keyed empty state.
|
||||
canCreate ? (
|
||||
<div className="directory-empty">
|
||||
<p className="directory-tagline">No projects yet.</p>
|
||||
<button className="btn-primary" onClick={() => setCreateOpen(true)}>
|
||||
Create your first project
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="directory-tagline">Nothing has been shared with you yet.</p>
|
||||
)
|
||||
) : (
|
||||
<ul className="directory-list">
|
||||
{projects.map(p => (
|
||||
<li key={p.id} className="directory-card">
|
||||
<Link to={`/p/${p.id}/`}>
|
||||
<span className="directory-card-name">{p.name}</span>
|
||||
<span className="directory-card-type">{entryNoun(p.type)}s</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{createOpen && (
|
||||
<CreateProjectModal
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreated={() => { setCreateOpen(false); refresh?.() }}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,24 +5,34 @@ import { MemoryRouter } from 'react-router-dom'
|
||||
|
||||
// Mocked first so Directory (and ProjectLayout, which it imports entryNoun
|
||||
// from) resolve the deployment hook and api without a live fetch.
|
||||
let mockProjects = []
|
||||
vi.mock('../api', () => ({ getProject: vi.fn() }))
|
||||
let mockDeployment = {}
|
||||
vi.mock('../api', () => ({ getProject: vi.fn(), createProject: vi.fn() }))
|
||||
vi.mock('../context/DeploymentProvider', () => ({
|
||||
useDeployment: () => ({ name: 'Wiggleverse', tagline: 'a directory of projects', projects: mockProjects, defaultProjectId: null }),
|
||||
useDeployment: () => mockDeployment,
|
||||
}))
|
||||
import Directory from './Directory.jsx'
|
||||
|
||||
function renderDir(projects) {
|
||||
mockProjects = projects
|
||||
function renderDir(overrides = {}) {
|
||||
mockDeployment = {
|
||||
name: 'Wiggleverse',
|
||||
tagline: 'a directory of projects',
|
||||
projects: [],
|
||||
viewer: null,
|
||||
defaultProjectId: null,
|
||||
refresh: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
return render(<MemoryRouter><Directory /></MemoryRouter>)
|
||||
}
|
||||
|
||||
describe('Directory', () => {
|
||||
it('renders a card per visible project with the type-driven noun + link', () => {
|
||||
renderDir([
|
||||
{ id: 'ohm', name: 'Open Human Model', type: 'document', visibility: 'public' },
|
||||
{ id: 'ecomm', name: 'Ecomm', type: 'bdd', visibility: 'public' },
|
||||
])
|
||||
renderDir({
|
||||
projects: [
|
||||
{ id: 'ohm', name: 'Open Human Model', type: 'document', visibility: 'public' },
|
||||
{ id: 'ecomm', name: 'Ecomm', type: 'bdd', visibility: 'public' },
|
||||
],
|
||||
})
|
||||
const ohm = screen.getByText('Open Human Model').closest('a')
|
||||
expect(ohm).toHaveAttribute('href', '/p/ohm/')
|
||||
const ecomm = screen.getByText('Ecomm').closest('a')
|
||||
@@ -33,13 +43,41 @@ describe('Directory', () => {
|
||||
})
|
||||
|
||||
it('renders the deployment name and tagline', () => {
|
||||
renderDir([{ id: 'ohm', name: 'OHM', type: 'document', visibility: 'public' }])
|
||||
renderDir({ projects: [{ id: 'ohm', name: 'OHM', type: 'document', visibility: 'public' }] })
|
||||
expect(screen.getByText('Wiggleverse')).toBeInTheDocument()
|
||||
expect(screen.getByText('a directory of projects')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders an empty list without crashing when no projects are visible', () => {
|
||||
renderDir([])
|
||||
renderDir({ projects: [] })
|
||||
expect(screen.getByText('Wiggleverse')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// §22 S5 — role-aware empty states (C3.1 / C3.2).
|
||||
it('shows a "Create your first project" CTA to a global Owner on an empty directory', () => {
|
||||
renderDir({ projects: [], viewer: { can_create_project: true } })
|
||||
expect(screen.getByText('Create your first project')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a "nothing shared with you yet" note to a non-owner on an empty directory', () => {
|
||||
renderDir({ projects: [], viewer: { can_create_project: false } })
|
||||
expect(screen.getByText(/Nothing has been shared with you yet/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText('Create your first project')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers a "New project" control to an Owner when the directory is non-empty', () => {
|
||||
renderDir({
|
||||
projects: [{ id: 'ohm', name: 'OHM', type: 'document', visibility: 'public' }],
|
||||
viewer: { can_create_project: true },
|
||||
})
|
||||
expect(screen.getByText('New project')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not offer a create control to a non-owner when the directory is non-empty', () => {
|
||||
renderDir({
|
||||
projects: [{ id: 'ohm', name: 'OHM', type: 'document', visibility: 'public' }],
|
||||
viewer: { can_create_project: false },
|
||||
})
|
||||
expect(screen.queryByText('New project')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// FacetGroups.jsx — §22.4a SLICE-3 faceted left-pane filters (§5.1).
|
||||
//
|
||||
// Renders one collapsible group per facet field returned by the API, each with
|
||||
// per-value result counts and multi-select checkboxes. A `tags`-type field gets
|
||||
// a "filter values…" search box so it stays usable at 30+ values. Selection
|
||||
// state + the malformed toggle are owned by the parent (Catalog), which
|
||||
// re-fetches server-side on change.
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
// Human label for the built-in state facet; declared fields use their own name
|
||||
// (or the schema `label` when the API supplies it on `fields`).
|
||||
function groupLabel(field, fields) {
|
||||
if (field === 'state') return 'State'
|
||||
const def = fields?.[field]
|
||||
return def?.label || field.charAt(0).toUpperCase() + field.slice(1)
|
||||
}
|
||||
|
||||
function FacetGroup({ field, fields, counts, selected, onToggle, isTags }) {
|
||||
const [open, setOpen] = useState(true)
|
||||
const [valueSearch, setValueSearch] = useState('')
|
||||
const entries = Object.entries(counts).sort((a, b) => b[1] - a[1])
|
||||
const needle = valueSearch.trim().toLowerCase()
|
||||
const shown = isTags && needle
|
||||
? entries.filter(([v]) => v.toLowerCase().includes(needle))
|
||||
: entries
|
||||
return (
|
||||
<div className="facet-group">
|
||||
<button className="facet-header" onClick={() => setOpen(o => !o)}>
|
||||
<span>{open ? '▾' : '▸'} {groupLabel(field, fields)}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="facet-values">
|
||||
{isTags && entries.length > 8 && (
|
||||
<input
|
||||
className="facet-value-search"
|
||||
placeholder="filter values…"
|
||||
value={valueSearch}
|
||||
onChange={e => setValueSearch(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{shown.length === 0 && (
|
||||
<div className="facet-empty">No values.</div>
|
||||
)}
|
||||
{shown.map(([value, count]) => (
|
||||
<label key={value} className="facet-value">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(value)}
|
||||
onChange={() => onToggle(field, value)}
|
||||
/>
|
||||
<span className="facet-value-label">{value}</span>
|
||||
<span className="facet-count">{count}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function FacetGroups({
|
||||
facets, fields, selections, onToggle, malformedOnly, onToggleMalformed,
|
||||
onClear, hasActiveFilters,
|
||||
}) {
|
||||
// The API returns facets in field order with `state` last; render in that
|
||||
// order. `selections` is { field: Set<string> }.
|
||||
const fieldOrder = Object.keys(facets || {})
|
||||
// A `tags`-type field renders the value-search box.
|
||||
const isTags = (field) => fields?.[field]?.type === 'tags'
|
||||
return (
|
||||
<div className="facets">
|
||||
{hasActiveFilters && (
|
||||
<button className="facet-clear" onClick={onClear}>Clear filters</button>
|
||||
)}
|
||||
{fieldOrder.map(field => (
|
||||
<FacetGroup
|
||||
key={field}
|
||||
field={field}
|
||||
fields={fields}
|
||||
counts={facets[field]}
|
||||
selected={selections[field] || new Set()}
|
||||
onToggle={onToggle}
|
||||
isTags={isTags(field)}
|
||||
/>
|
||||
))}
|
||||
<label className="facet-malformed">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={malformedOnly}
|
||||
onChange={onToggleMalformed}
|
||||
/>
|
||||
<span>Malformed metadata only</span>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,7 +12,9 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
acceptContributionRequest,
|
||||
acceptJoinRequest,
|
||||
declineContributionRequest,
|
||||
declineJoinRequest,
|
||||
listNotifications,
|
||||
markNotificationRead,
|
||||
markNotificationsReadByFilter,
|
||||
@@ -228,11 +230,76 @@ function ContributionRequestRow({ item, onMarkRead }) {
|
||||
)
|
||||
}
|
||||
|
||||
// §22.8: the cross-collection inbox row — a request to join a scope, surfaced
|
||||
// to that scope's Owners across the subtree. Mirrors ContributionRequestRow:
|
||||
// the requester's role + message inline, an Accept/Decline pair that writes (or
|
||||
// refuses) the membership grant.
|
||||
function JoinRequestRow({ item, onMarkRead }) {
|
||||
const unread = !item.read_at
|
||||
const x = item.extras || {}
|
||||
const [outcome, setOutcome] = useState(null) // 'accepted' | 'declined'
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
async function act(accept) {
|
||||
if (busy || outcome) return
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
if (accept) await acceptJoinRequest(x.scope_type, x.scope_id, x.request_id)
|
||||
else await declineJoinRequest(x.scope_type, x.scope_id, x.request_id)
|
||||
setOutcome(accept ? 'accepted' : 'declined')
|
||||
await onMarkRead(item)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Action failed.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const roleLabel = x.requested_role === 'owner' ? 'Owner' : 'RFC Contributor'
|
||||
|
||||
return (
|
||||
<li className={`inbox-row inbox-row-action ${unread ? 'unread' : 'read'}`}>
|
||||
<div className="inbox-row-main">
|
||||
<span className="inbox-unread-dot" aria-hidden />
|
||||
<span className={`inbox-cat cat-${item.category || 'unknown'}`}>{item.category || '·'}</span>
|
||||
<span className="inbox-summary">{item.summary}</span>
|
||||
<span className="inbox-when">{formatWhen(item.created_at)}</span>
|
||||
</div>
|
||||
<div className="inbox-request-detail">
|
||||
<p><strong>Requested role:</strong> {roleLabel}</p>
|
||||
{x.message && <p><strong>Message:</strong> {x.message}</p>}
|
||||
</div>
|
||||
{error && <p className="field-error">{error}</p>}
|
||||
{outcome ? (
|
||||
<p className="inbox-request-outcome muted">
|
||||
{outcome === 'accepted'
|
||||
? `Accepted — they're now a member as ${roleLabel}.`
|
||||
: 'Declined.'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="inbox-request-actions">
|
||||
<button type="button" className="btn-primary" disabled={busy || !x.request_id} onClick={() => act(true)}>
|
||||
Accept
|
||||
</button>
|
||||
<button type="button" className="btn-secondary" disabled={busy || !x.request_id} onClick={() => act(false)}>
|
||||
Decline
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function InboxRow({ item, onClick, onMarkRead, onClose }) {
|
||||
const pid = useProjectId()
|
||||
if (item.event_kind === 'contribution_request_on_pending_rfc') {
|
||||
return <ContributionRequestRow item={item} onMarkRead={onMarkRead} />
|
||||
}
|
||||
if (item.event_kind === 'join_request_on_scope') {
|
||||
return <JoinRequestRow item={item} onMarkRead={onMarkRead} />
|
||||
}
|
||||
const unread = !item.read_at
|
||||
const target = deepLink(item, pid)
|
||||
const handle = async () => {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// JoinRequestModal.jsx — §22.8: request-to-join a scope.
|
||||
//
|
||||
// A gated project or collection is invisible to non-members, so a user who
|
||||
// knows it exists asks to join — naming a desired role ({owner, contributor})
|
||||
// and an optional message. The request is recorded and fanned out to the
|
||||
// scope's Owners across the subtree (the cross-collection inbox), who accept
|
||||
// (writing the membership row) or decline.
|
||||
//
|
||||
// Sibling of ScopeMembersModal (the Owner's grant surface) and the per-RFC
|
||||
// ContributeRequestForm (the per-entry ask). Opens from the "Request to join"
|
||||
// affordance the directory / collection view shows when the viewer's
|
||||
// `can_request_join` flag is set.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { joinTarget, requestJoin } from '../api'
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ value: 'contributor', label: 'RFC Contributor — propose entries in the scope' },
|
||||
{ value: 'owner', label: 'Owner — administer the scope and its membership' },
|
||||
]
|
||||
|
||||
export default function JoinRequestModal({ scopeType, scopeId, scopeName, onClose }) {
|
||||
const [target, setTarget] = useState(null)
|
||||
const [role, setRole] = useState('contributor')
|
||||
const [message, setMessage] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [done, setDone] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
joinTarget(scopeType, scopeId)
|
||||
.then(t => { if (live) setTarget(t) })
|
||||
.catch(e => { if (live) setError(e.message || 'Could not load this scope.') })
|
||||
return () => { live = false }
|
||||
}, [scopeType, scopeId])
|
||||
|
||||
const label = (target && target.name) || scopeName || scopeId
|
||||
const where = scopeType === 'collection' ? 'collection' : 'project'
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
if (submitting) return
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
await requestJoin(scopeType, scopeId, { role, message: message.trim() || null })
|
||||
setDone(true)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to send the request.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="modal" style={{ maxWidth: 520 }}>
|
||||
<div className="modal-header">
|
||||
<h2>Request to join</h2>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{done ? (
|
||||
<p>
|
||||
Your request to join {where} <strong>{label}</strong> has been sent to
|
||||
its Owners. You'll get an inbox notification when it's decided.
|
||||
</p>
|
||||
) : target && !target.eligible ? (
|
||||
<p style={{ color: '#666' }}>
|
||||
{target.already_requested
|
||||
? `You already have a pending request to join ${where} ${label}.`
|
||||
: (target.reason || 'You cannot request to join this scope.')}
|
||||
</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="invitations-form">
|
||||
<p style={{ marginTop: 0, color: '#666' }}>
|
||||
Ask the Owners of {where} <strong>{label}</strong> for a role. An{' '}
|
||||
<strong>RFC Contributor</strong> may propose entries; an{' '}
|
||||
<strong>Owner</strong> administers the scope.
|
||||
</p>
|
||||
<label htmlFor="join-role">Role</label>
|
||||
<select id="join-role" value={role} onChange={e => setRole(e.target.value)}>
|
||||
{ROLE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<label htmlFor="join-message" style={{ marginTop: 10 }}>
|
||||
Message <span style={{ color: '#999' }}>(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="join-message"
|
||||
value={message}
|
||||
onChange={e => setMessage(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Who you are and why you'd like to join."
|
||||
maxLength={4000}
|
||||
/>
|
||||
<div style={{ marginTop: 12, display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<button type="submit" className="btn-primary" disabled={submitting}>
|
||||
{submitting ? 'Sending…' : 'Send request'}
|
||||
</button>
|
||||
{error && <span style={{ color: '#c33' }}>{error}</span>}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn-link" onClick={onClose}>
|
||||
{done ? 'Close' : 'Cancel'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import React, { useState } from 'react'
|
||||
import { saveEntryMeta } from '../api'
|
||||
|
||||
// §22.4a SLICE-4 (PUC-1, UX §5.2): a schema-driven view/edit panel for one
|
||||
// entry's metadata. One control per declared field — enum → single-select,
|
||||
// tags → removable chips + add-input, text → text input. Authorized users
|
||||
// (canEdit) save changed values via a direct sidecar commit; the document body
|
||||
// renders separately as pure prose. A collection with no `fields` renders
|
||||
// nothing (INV-5: today's behavior unchanged).
|
||||
|
||||
const labelFor = (name, def) =>
|
||||
def?.label || name.charAt(0).toUpperCase() + name.slice(1)
|
||||
|
||||
const sameValue = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null)
|
||||
|
||||
export default function MetadataFieldsPanel({
|
||||
projectId, collectionId, slug, fields, meta, canEdit,
|
||||
}) {
|
||||
const [draft, setDraft] = useState(() => ({ ...(meta || {}) }))
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [tagInput, setTagInput] = useState('')
|
||||
|
||||
if (!fields || Object.keys(fields).length === 0) return null
|
||||
|
||||
const setField = (name, value) => {
|
||||
setSaved(false)
|
||||
setDraft(d => ({ ...d, [name]: value }))
|
||||
}
|
||||
|
||||
const changedFields = Object.keys(fields).filter(
|
||||
n => !sameValue(draft[n], (meta || {})[n]))
|
||||
const changed = changedFields.length > 0
|
||||
|
||||
const onSave = async () => {
|
||||
setSaving(true); setError(null); setSaved(false)
|
||||
const values = {}
|
||||
for (const n of changedFields) values[n] = draft[n]
|
||||
try {
|
||||
await saveEntryMeta(projectId, collectionId, slug, values)
|
||||
setSaved(true)
|
||||
} catch (e) {
|
||||
setError(e.message || 'Could not save metadata')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="metadata-fields-panel">
|
||||
{Object.entries(fields).map(([name, def]) => (
|
||||
<div key={name} className="metadata-field">
|
||||
<label htmlFor={`mf-${name}`}>{labelFor(name, def)}</label>
|
||||
{!canEdit ? (
|
||||
<ReadOnlyValue type={def.type} value={draft[name]} />
|
||||
) : def.type === 'enum' ? (
|
||||
<select id={`mf-${name}`} value={draft[name] ?? ''}
|
||||
onChange={e => setField(name, e.target.value || null)}>
|
||||
<option value="">—</option>
|
||||
{(def.values || []).map(v => <option key={v} value={v}>{v}</option>)}
|
||||
</select>
|
||||
) : def.type === 'tags' ? (
|
||||
<div className="tags-edit">
|
||||
{(draft[name] || []).map(t => (
|
||||
<span key={t} className="tag-chip">
|
||||
{t}
|
||||
<button type="button" aria-label={`remove ${t}`}
|
||||
onClick={() => setField(name, (draft[name] || []).filter(x => x !== t))}>×</button>
|
||||
</span>
|
||||
))}
|
||||
<input id={`mf-${name}`} value={tagInput}
|
||||
onChange={e => setTagInput(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && tagInput.trim()) {
|
||||
e.preventDefault()
|
||||
const cur = draft[name] || []
|
||||
if (!cur.includes(tagInput.trim())) setField(name, [...cur, tagInput.trim()])
|
||||
setTagInput('')
|
||||
}
|
||||
}} placeholder="add tag…" />
|
||||
</div>
|
||||
) : (
|
||||
<input id={`mf-${name}`} type="text" value={draft[name] ?? ''}
|
||||
onChange={e => setField(name, e.target.value || null)} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{canEdit && (
|
||||
<div className="metadata-fields-actions">
|
||||
<button type="button" onClick={onSave} disabled={saving || !changed}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
{saved && !error && <span className="field-saved">Saved</span>}
|
||||
{error && <span className="field-error">{error}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReadOnlyValue({ type, value }) {
|
||||
if (type === 'tags') {
|
||||
return (
|
||||
<span>
|
||||
{(value || []).map(t => <span key={t} className="tag-chip">{t}</span>)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return <span>{value ?? '—'}</span>
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user