§22 S6: type-driven entry noun (§22.4a) — backend source of truth + chrome

The displayed noun for an entry is a framework concept keyed on the collection's
immutable type — document→"RFC", specification→"Spec", bdd→"Feature" — not
deployment content. The chrome reads it from the API instead of hardcoding "RFC".

- collections.ENTRY_NOUN + entry_noun(type) (unknown type → generic "RFC").
- Surfaced as entry_noun on get_collection, list_collections items, the
  /api/deployment directory items, and GET /api/projects/:id.
- Frontend: Catalog reads entry_noun for the "+ Propose New <noun>" control;
  ProposeModal fetches the active collection's noun for its title + field copy.
- test_s6_entry_noun_vertical.py: map + API surfacing (collection + directory).

Scope note: this is §22.4a item (2) — terminology. The deeper type-module work
(item 1 per-type frontmatter schemas; item 3 specification release-planning +
bdd scenario/coverage surfaces) is flagged OPEN in the design doc and is handed
off to a follow-up spec+slice.

Backend 528 passed; frontend 30 passed + build green. Part of v0.45.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-06 01:35:57 -07:00
parent 79a27a946b
commit 839404da0c
5 changed files with 104 additions and 10 deletions
+6
View File
@@ -78,6 +78,11 @@ def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
"type": collections_mod.collection_type( "type": collections_mod.collection_type(
collections_mod.default_collection_id(r["id"]) 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"], "visibility": r["visibility"],
} }
for r in rows for r in rows
@@ -221,6 +226,7 @@ def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
"name": row["name"], "name": row["name"],
"tagline": (dep["tagline"] if dep else "") or "", "tagline": (dep["tagline"] if dep else "") or "",
"type": collections_mod.collection_type(cid), "type": collections_mod.collection_type(cid),
"entry_noun": collections_mod.entry_noun(collections_mod.collection_type(cid)),
"visibility": row["visibility"], "visibility": row["visibility"],
"initial_state": collections_mod.collection_initial_state(cid), "initial_state": collections_mod.collection_initial_state(cid),
"theme": cfg.get("theme") or {}, "theme": cfg.get("theme") or {},
+21 -1
View File
@@ -14,6 +14,23 @@ from . import db
DEFAULT_COLLECTION_ID = "default" 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: 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 """§22.12 per-collection enabled_models from a `config_json` blob, or None
@@ -86,6 +103,7 @@ def get_collection(collection_id: str) -> dict | None:
return None return None
out = dict(row) out = dict(row)
out["enabled_models"] = _enabled_models_from_config(out.pop("config_json", None)) out["enabled_models"] = _enabled_models_from_config(out.pop("config_json", None))
out["entry_noun"] = entry_noun(out["type"])
return out return out
@@ -102,5 +120,7 @@ def list_collections(project_id: str, include_unlisted: bool = False) -> list[di
for r in rows: for r in rows:
if not include_unlisted and r["visibility"] == "unlisted": if not include_unlisted and r["visibility"] == "unlisted":
continue continue
out.append(dict(r)) item = dict(r)
item["entry_noun"] = entry_noun(item["type"])
out.append(item)
return out return out
@@ -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"
+8 -2
View File
@@ -31,6 +31,9 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
// collection's caps load; we fall back to "any authenticated viewer" so the // collection's caps load; we fall back to "any authenticated viewer" so the
// common case doesn't flicker, then refine. // common case doesn't flicker, then refine.
const [canContribute, setCanContribute] = useState(null) const [canContribute, setCanContribute] = useState(null)
// §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 [search, setSearch] = useState('')
const [sort, setSort] = useState('recent') const [sort, setSort] = useState('recent')
const [activeChips, setActiveChips] = useState(new Set()) const [activeChips, setActiveChips] = useState(new Set())
@@ -46,7 +49,10 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
listProposals(pid).then(d => setProposals(d.items)).catch(() => setProposals([])) listProposals(pid).then(d => setProposals(d.items)).catch(() => setProposals([]))
setCanContribute(null) setCanContribute(null)
getCollection(pid, cid) getCollection(pid, cid)
.then(c => setCanContribute(!!c?.viewer?.can_contribute)) .then(c => {
setCanContribute(!!c?.viewer?.can_contribute)
setEntryNoun(c?.entry_noun || 'RFC')
})
.catch(() => setCanContribute(false)) .catch(() => setCanContribute(false))
}, [version, pid, cid]) }, [version, pid, cid])
@@ -177,7 +183,7 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
// contribute to *this* collection (the scope-role gate); a granted // contribute to *this* collection (the scope-role gate); a granted
// viewer with no contribute right in this collection sees nothing. // viewer with no contribute right in this collection sees nothing.
mayPropose && ( mayPropose && (
<button className="btn-propose" onClick={onProposeRFC}>+ Propose New RFC</button> <button className="btn-propose" onClick={onProposeRFC}>+ Propose New {entryNoun}</button>
) )
) : ( ) : (
<a className="btn-propose" href="/auth/login" title="Private beta — only invited emails can propose"> <a className="btn-propose" href="/auth/login" title="Private beta — only invited emails can propose">
+21 -7
View File
@@ -12,7 +12,7 @@
// success navigates the proposer to the pending-idea view per §9.3. // success navigates the proposer to the pending-idea view per §9.3.
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { proposeRFC, suggestTags } from '../api' import { proposeRFC, suggestTags, getCollection } from '../api'
import { EVENTS, track } from '../lib/analytics' import { EVENTS, track } from '../lib/analytics'
// How long the draft must sit unchanged before we ask for suggestions — // How long the draft must sit unchanged before we ask for suggestions —
@@ -33,6 +33,9 @@ export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitl
// (App passes the `?propose=<term>` value here); the slug derives from it // (App passes the `?propose=<term>` value here); the slug derives from it
// via the same effect that drives manual typing. // via the same effect that drives manual typing.
const [title, setTitle] = useState(initialTitle) const [title, setTitle] = useState(initialTitle)
// §22.4a: the type-driven noun for the active collection ("RFC" | "Spec" |
// "Feature"), read from the API. Defaults to "RFC" until the collection loads.
const [entryNoun, setEntryNoun] = useState('RFC')
const [slug, setSlug] = useState('') const [slug, setSlug] = useState('')
const [slugEdited, setSlugEdited] = useState(false) const [slugEdited, setSlugEdited] = useState(false)
const [pitch, setPitch] = useState('') const [pitch, setPitch] = useState('')
@@ -53,6 +56,17 @@ export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitl
if (!slugEdited) setSlug(slugify(title)) if (!slugEdited) setSlug(slugify(title))
}, [title, slugEdited]) }, [title, slugEdited])
// §22.4a: resolve the active collection's entry noun so the modal chrome
// names entries per the collection type. Best-effort; falls back to "RFC".
useEffect(() => {
if (!projectId || !collectionId) return
let live = true
getCollection(projectId, collectionId)
.then(c => { if (live) setEntryNoun(c?.entry_noun || 'RFC') })
.catch(() => {})
return () => { live = false }
}, [projectId, collectionId])
// #27: debounced tag-suggestion fetch. Fires after the draft sits // #27: debounced tag-suggestion fetch. Fires after the draft sits
// unchanged for SUGGEST_DEBOUNCE_MS, only once there's something to go // unchanged for SUGGEST_DEBOUNCE_MS, only once there's something to go
// on (a title). A stale-response guard keeps an earlier in-flight // on (a title). A stale-response guard keeps an earlier in-flight
@@ -116,7 +130,7 @@ export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitl
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}> <div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="modal"> <div className="modal">
<div className="modal-header"> <div className="modal-header">
<h2>Propose a New RFC</h2> <h2>Propose a New {entryNoun}</h2>
<button className="modal-close" onClick={onClose}>×</button> <button className="modal-close" onClick={onClose}>×</button>
</div> </div>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
@@ -130,7 +144,7 @@ export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitl
autoFocus autoFocus
required required
/> />
<p className="field-help">The word or topic this RFC will define.</p> <p className="field-help">The word or topic this {entryNoun} will define.</p>
<label htmlFor="propose-slug">Slug</label> <label htmlFor="propose-slug">Slug</label>
<input <input
@@ -142,22 +156,22 @@ export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitl
/> />
<p className="field-help">Auto-derived from the title; edit if needed.</p> <p className="field-help">Auto-derived from the title; edit if needed.</p>
<label htmlFor="propose-pitch">Why is this RFC needed?</label> <label htmlFor="propose-pitch">Why is this {entryNoun} needed?</label>
<textarea <textarea
id="propose-pitch" id="propose-pitch"
value={pitch} value={pitch}
onChange={e => setPitch(e.target.value)} onChange={e => setPitch(e.target.value)}
placeholder="One or two paragraphs answering 'why this RFC is needed.'" placeholder={`One or two paragraphs answering 'why this ${entryNoun} is needed.'`}
rows={5} rows={5}
required required
/> />
<label htmlFor="propose-use-case">What will you be using this RFC for? (optional)</label> <label htmlFor="propose-use-case">What will you be using this {entryNoun} for? (optional)</label>
<textarea <textarea
id="propose-use-case" id="propose-use-case"
value={useCase} value={useCase}
onChange={e => setUseCase(e.target.value)} onChange={e => setUseCase(e.target.value)}
placeholder="The concrete thing you intend to build or do with this RFC. Optional, but it helps ground the work." placeholder={`The concrete thing you intend to build or do with this ${entryNoun}. Optional, but it helps ground the work.`}
rows={3} rows={3}
/> />
<p className="field-help"> <p className="field-help">