// The tiered doc viewer: a reading mode over records the space already holds. // // Radial's System page is a work-tracking list — rows, states, verdicts, who is holding what. That // is the right screen for somebody maintaining the project's standing memory and the wrong one for // somebody trying to read it. The docs route is the other screen over the same records, in three // tiers: // // 1. **Dev docs** — the system artifacts verbatim. ADRs, the architecture document, conventions: // whatever project-scoped types the registry carries, minus the two below. // 2. **User docs** — product documentation, one unit per feature page, whose driving request pins // by strongref the system artifacts it was distilled from. // 3. **Roadmap docs** — community-facing pages, one unit per epic, whose driving request pins // GOALS instead, so the road ahead and the road travelled are derived rather than authored. // // Nothing here is a new kind of record. Tiers 2 and 3 are two entries in the artifact-type registry // (design §4), which buys versioning, reviews, threads and turn-bundle inclusion for free and leaves // the additive-only lexicon invariant untouched — the doc tiers add no record type, so `core`'s // golden scenario is unmoved and `packages/core/test/source-drift.test.mjs` asserts exactly that. // // Two rules this module exists to keep in one place: // // - **The type names are constants, once.** A tier is a registry name, and a name scattered // through components is a rule nobody can find. `docTier()` is the only thing that knows them. // - **An edit request is assigned to its author.** An unassigned open request is claimable by any // operator's daemon, so a human's edit-in-progress that named nobody could be raced to a // verdict by a container. `editRequestArgs` always sends `--assignee`, and it is not optional. import { artifactTypes, durabilityOf, sourceDrift, sourceGoals, staleness, type GoalView, type MaterializedIndex, type ProjectView, type SourceDrift, type StrongRef, type UnitVersion, type UnitView, } from '@radial/core' import type { TypeDraft } from './admin.js' import { locator } from './requests.js' import { unitsOf, withSpace } from './space.js' import { typeLabel, type RowBadge } from './units.js' /** The registry name of the user-facing documentation tier. */ export const USER_DOC_TYPE = 'user-doc' /** The registry name of the community-facing roadmap tier. */ export const ROADMAP_DOC_TYPE = 'roadmap-doc' export type TierId = 'dev' | 'user' | 'roadmap' export interface Tier { id: TierId label: string /** One line under the tier's heading: what a reader is looking at. */ blurb: string /** * The registry type this tier IS, where it is one type. `dev` has none on purpose — it is every * other project-scoped type, so registering a new one puts it in the dev tier with no code change, * which is the same promise the ⊕ menu makes. */ type?: string /** What the sources of a doc in this tier are, in the words the edit form uses. */ sources?: string } export const TIERS: readonly Tier[] = [ { id: 'dev', label: 'Dev', blurb: 'The project’s standing memory, as the agents read it. Every version of these goes to an agent whenever it takes on work here.', }, { id: 'user', label: 'User', blurb: 'Product documentation, one page per feature, written from the pages above.', type: USER_DOC_TYPE, sources: 'the system pages this page documents', }, { id: 'roadmap', label: 'Roadmap', blurb: 'Where an epic has got to and where it is going, from the goals underneath it.', type: ROADMAP_DOC_TYPE, sources: 'the goals this epic is made of', }, ] export const tierById = (id: TierId): Tier => TIERS.find((tier) => tier.id === id) as Tier /** Which tier a unit is drawn in. Everything project-scoped that is not a doc type is a dev doc. */ export const docTier = (unit: Pick): TierId => unit.type === USER_DOC_TYPE ? 'user' : unit.type === ROADMAP_DOC_TYPE ? 'roadmap' : 'dev' /** * The project's units, split into the three tiers and ordered for reading rather than for tracking. * * Alphabetical by title within a tier — a docs sidebar is scanned by name, unlike the System list, * which is scanned by what moved most recently. A retracted request is not a document and is left * out entirely; it is still a row on System, where withdrawn work belongs. */ export function docsOf( index: MaterializedIndex, project: ProjectView, ): Record { const tiers: Record = { dev: [], user: [], roadmap: [] } for (const unit of unitsOf(index, project)) { if (unit.retracted) continue tiers[docTier(unit)].push(unit) } for (const id of Object.keys(tiers) as TierId[]) { tiers[id].sort( (left, right) => docTitle(left).localeCompare(docTitle(right)) || left.key.localeCompare(right.key), ) } return tiers } /** * What this document is called. The artifact's own `title` is the field the writers are obliged to * fill (`packages/lexicons/README.md`); a record from before the field existed falls back to its * type, which is what every other surface shows for one. */ export const docTitle = (unit: UnitView): string => unit.current?.artifact.value.title?.trim() || unit.type /** One kind of document in a tier: the registry type, as prose, plus the pages that are one. */ export interface DocGroup { /** The registry name — the group's identity, and what a collapsed state is keyed on. */ type: string /** The name as it is written (`typeLabel`): `adr` is spoken as an acronym, `architecture` is not. */ label: string units: UnitView[] } /** * A tier's pages, grouped by the type of document they are. * * The dev tier is every project-scoped type that is not one of the two doc types, so it is a mixed * list by construction — ADRs, an architecture document, conventions, and whatever an admin * registers tomorrow. Read as one alphabetical run those interleave, and a reader looking for "the * ADRs" has to know which titles are ADRs. Grouping is therefore the registry's own structure shown * rather than a taxonomy this file invents, which is also why nothing here is ordered by a list of * known names: groups come out alphabetically by label, and a type nobody has seen before takes its * place among them. * * The order WITHIN a group is the order it was given — `docsOf` has already sorted for reading. */ export function docGroups(units: readonly UnitView[]): DocGroup[] { const groups = new Map() for (const unit of units) { const group = groups.get(unit.type) ?? { type: unit.type, label: typeLabel(unit.type), units: [] } group.units.push(unit) groups.set(unit.type, group) } return [...groups.values()].sort( (left, right) => left.label.localeCompare(right.label) || left.type.localeCompare(right.type), ) } /** Whether a doc in this tier is written by a member in the app rather than asked of an agent. */ export const isEditable = (id: TierId): boolean => id !== 'dev' // ── drift, twice ──────────────────────────────────────────────────────────────────────────────── /** * The two drift signals a doc page shows, side by side and never summed. * * They answer different questions and a reader acts on them differently. **Merge drift** is * `staleness()`: the CODE moved — implementations have merged under this project since this version * landed, so the document may describe something that is no longer true. **Source drift** is * `sourceDrift()`: the SOURCES moved — a record this page was distilled from has been superseded, * which is a specific, citable thing to go and read. * * A dev doc gets only the first, because it has no distilled sources to drift from; it IS a source. */ export interface DocDrift { /** Merges under the project since the current version landed. 0 for an append-only type. */ merges: number /** Superseded sources, in the order the request pinned them. */ sources: SourceDrift[] } export function docDrift( index: MaterializedIndex, project: ProjectView, unit: UnitView, ): DocDrift { return { merges: staleness(index, project.target.uri, unit), sources: isEditable(docTier(unit)) ? sourceDrift(index, unit) : [], } } export const hasDrifted = (drift: DocDrift): boolean => drift.merges > 0 || drift.sources.length > 0 /** * The badges on a doc row. Distinct wording on purpose: "3 merges" and "1 source" answer different * questions, and a single "4 drift" would be the app adding two numbers that do not add. */ export function driftBadges(drift: DocDrift): RowBadge[] { const badges: RowBadge[] = [] if (drift.sources.length > 0) { badges.push({ kind: 'warn', glyph: '↑', text: `${drift.sources.length} source${drift.sources.length === 1 ? '' : 's'} moved on`, narrow: `${drift.sources.length} src`, title: drift.sources .map((entry) => `${sourceLabel(entry)} — this page documents v${entry.pinned.version}`) .join('\n'), }) } if (drift.merges > 0) { badges.push({ kind: 'flat', glyph: 'Δ', text: `${drift.merges} merge${drift.merges === 1 ? '' : 's'} since`, narrow: `${drift.merges}Δ`, title: `${drift.merges} implementation(s) merged under this project since this version landed`, }) } return badges } /** How a drifted source is named in a sentence: its title, or the type of record it is. */ export const sourceLabel = (entry: SourceDrift): string => entry.current.artifact.value.title?.trim() || entry.pinned.artifact.value.title?.trim() || entry.unit.type /** "documents v1 · now at v3" — the whole of what a source-drift row has to say. */ export const sourceDriftNote = (entry: SourceDrift): string => `documents v${entry.pinned.version} · now at v${entry.current.version}` // ── the roadmap tier ──────────────────────────────────────────────────────────────────────────── export interface RoadmapEntry { goal: GoalView /** The merged implementations under this goal, oldest first: "here's where we got web push". */ landed: Array<{ unit: UnitView; version: UnitVersion }> } export interface RoadmapSections { /** Live goals: the road ahead. */ ahead: RoadmapEntry[] /** Ended goals with what merged under them: the road travelled. */ travelled: RoadmapEntry[] } /** * A roadmap page's two derived sections, resolved from its request's goal refs at READ time. * * Derived rather than authored is the whole point: closing a goal moves it from one section to the * other on the next sync, with nobody editing the page. What a person writes on a roadmap doc is the * part a list of goals cannot say. * * The goals keep the order the request pinned them in — the author's ordering of their own epic — * and each carries the merged implementations under it, which is the thread's "here's where we got * mobile push" view without a second record type to hold it. */ export function roadmapSections(index: MaterializedIndex, unit: UnitView): RoadmapSections { const sections: RoadmapSections = { ahead: [], travelled: [] } for (const goal of sourceGoals(index, unit)) { const landed = unitsOf(index, goal) .filter((candidate) => !candidate.retracted) .flatMap((candidate) => candidate.versions.filter((version) => version.merged).map((version) => ({ unit: candidate, version })), ) .sort((left, right) => (left.version.mergedAt ?? '').localeCompare(right.version.mergedAt ?? '') || left.unit.key.localeCompare(right.unit.key), ) sections[goal.ended ? 'travelled' : 'ahead'].push({ goal, landed }) } return sections } // ── setting the tiers up ──────────────────────────────────────────────────────────────────────── /** * The two registry entries the editable tiers are, as `type create` drafts. * * The briefs are written for an AGENT, because a brief IS the turn prompt the day somebody asks an * agent to write or refresh one of these pages. That the same registry entry serves a person typing * in the browser and a container running a turn is the reason these are registry types at all. */ export const DOC_TYPE_DRAFTS: readonly TypeDraft[] = [ { name: USER_DOC_TYPE, scope: 'project', durability: 'living', description: 'A user-facing feature page', brief: 'Write the user-facing page for one feature, in the register of product documentation: what it does for a reader, what they see, and what it costs them. The records this is based on are the ground truth — distil them, never restate them, and never mention a record type or an implementation detail a user cannot see.', }, { name: ROADMAP_DOC_TYPE, scope: 'project', durability: 'living', description: 'A community-facing roadmap page', brief: 'Write the community-facing page for one epic. Say where the work has got to and where it is going, in a reader’s words. The goals this is based on supply the road ahead and the road travelled; write the part a list of goals cannot say.', }, ] /** * The doc types this space has not registered yet. * * Registering them is load-bearing rather than cosmetic, and stronger than a grouping: `materialize()` * drops a request whose `type` no registry entry names — it cannot even tell which scope it anchors * in — so before the entries exist, a doc written into this space folds into nothing at all. That is * why the empty state is a setup card and not an invitation to write the first page. Registering the * type later folds every such record back in, with nothing rewritten. */ export const missingDocTypes = (index: MaterializedIndex): TypeDraft[] => { const registry = artifactTypes(index) return DOC_TYPE_DRAFTS.filter((draft) => !registry.has(draft.name)) } // ── writing a doc ─────────────────────────────────────────────────────────────────────────────── /** * The sources a new version should pin: the previous request's refs, re-pinned to the CURRENT head * of each chain. * * Re-pinning is what "I have read the sources and this page now reflects them" means, and it is the * only thing that clears source drift — a version that carried its predecessor's stale refs forward * would look updated and still be badged. Refs the fold cannot resolve (a goal, a record from a repo * this tab has not read) are carried through untouched: they are somebody's stated provenance, and * dropping one because this observer cannot see it would quietly rewrite what the doc is about. */ export function repinnedSources(index: MaterializedIndex, unit: UnitView): StrongRef[] { const request = unit.current?.request if (!request) return [] const drifted = new Map(sourceDrift(index, unit).map((entry) => [entry.ref.uri, entry.current])) return request.value.basedOn // The predecessor of this very chain is not a source; the successor pins its own predecessor // below, and carrying the old one through would pin a version that is about to stop being head. .filter((reference) => !unit.versions.some((version) => version.artifact.uri === reference.uri)) .map((reference) => { const head = drifted.get(reference.uri) return head ? { uri: head.artifact.uri, cid: head.artifact.cid } : reference }) } /** * `radial request create` for a doc edit: project-scoped, assigned to its own author, pinning the * predecessor plus every source. * * The self-assignment is the load-bearing argument. `dispatch.ts` refuses a request whose assignee is * not an active agent member, so a request named to a person is one no daemon anywhere will claim or * run — which is what makes it safe for a half-written edit to sit open in somebody's repo. Leave it * off and the request is open to any capable agent, and an operator's container may answer a page * somebody was in the middle of typing. */ export function editRequestArgs(input: { project: ProjectView type: string /** The author's own DID. Not optional: see above. */ did: string /** The version being revised, when this is an edit rather than a new page. */ prev?: UnitVersion sources: StrongRef[] }): string[] { const args = ['request', 'create', '--project', input.project.target.uri, '--type', input.type] if (input.prev) args.push('--based-on', locator(input.prev.artifact)) for (const reference of input.sources) args.push('--based-on', locator(reference)) args.push('--assignee', input.did) return args } /** * `radial artifact post` — the second half, and the same command a turn's sidecar runs. A browser has * no filesystem, so the body is inline (`--body`); everything else is a reference the CLI pins. */ export function docPostArgs(input: { request: Pick title: string body: string prev?: UnitVersion }): string[] { const args = ['artifact', 'post', '--request', locator(input.request)] if (input.prev) args.push('--prev', locator(input.prev.artifact)) return [...args, '--title', input.title.trim(), '--body', input.body] } /** * An edit somebody started and did not finish: an open doc request of theirs with nothing posted * against it. * * Saving is two writes — the request, then the artifact — and a failure between them leaves the first * standing. That is harmless (a request assigned to a person is dispatched by nobody) and it is * resumable, so the page offers to finish it rather than writing a second request for the same edit * and forking the unit's history over a dropped connection. */ export function resumableEdit( index: MaterializedIndex, project: ProjectView, did: string, ): Array<{ unit: UnitView; request: NonNullable }> { if (!did) return [] return unitsOf(index, project) .filter((unit) => isEditable(docTier(unit)) && !unit.retracted) .flatMap((unit) => { const request = unit.openRequest return request && request.value.assignee === did && request.did === did ? [{ unit, request }] : [] }) } /** * Two members editing one page in the same ingestion window both post `prev = v3`. The fold resolves * a deterministic head and loses nothing — both records are in the version rail — but one person's * edit stops being what a reader sees, and they should hear that from the page rather than discover * it. So the head is re-read at save time and compared with the one the form was opened on. * * A warning, never a refusal: this is the trade `labelGoal` already makes, and building a merge here * would be building a merge into a protocol that deliberately has none. */ export const headMoved = (unit: UnitView, openedOn: string | undefined): boolean => Boolean(openedOn && unit.current && unit.current.artifact.uri !== openedOn) // ── routes ────────────────────────────────────────────────────────────────────────────────────── export const docsHref = (project: ProjectView, tier?: TierId, unit?: Pick): string => { const base = withSpace( `/p/${encodeURIComponent(project.name)}/docs`, project.target.value.space.uri, ) const [path, query = ''] = base.split('?') const params = new URLSearchParams(query) if (tier && tier !== 'dev') params.set('tier', tier) if (unit) params.set('doc', unit.key) const encoded = params.toString() return `${path}${encoded ? `?${encoded}` : ''}` } /** The tier a URL asks for, defaulting to the one every space has something in. */ export const tierFromParam = (value: string | null): TierId => TIERS.some((tier) => tier.id === value) ? (value as TierId) : 'dev' /** Durability, in the words a reader of a docs site would use rather than the registry's. */ export const durabilityNote = (index: MaterializedIndex, unit: UnitView): string => durabilityOf(index, unit.type) === 'append-only' ? 'This page is part of a dated history.' : 'This page is kept current; earlier versions stay readable.'