diff --git a/docs/design.md b/docs/design.md index 3730e77..c7226f4 100644 --- a/docs/design.md +++ b/docs/design.md @@ -188,11 +188,11 @@ The two had the identical effect on every daemon pump — one capability with tw ## 8. System artifacts (project scope) -Most artifacts are working memory: a plan matters for the lifetime of its goal, then survives only as historical context. Some deliverables outlive any goal — an architecture overview, an inventory of what the system is made of, a glossary of the names the project uses for those parts, decision records (ADRs), conventions and onboarding docs. These are **system artifacts**: ordinary artifacts whose *type* declares `scope: project` in the registry. Nothing else about them is special — same request lifecycle, same `prev` versioning, same reviews (auto-review included; a wrong architecture doc is the most expensive artifact to leave unreviewed), same conversation — messages can anchor to an artifact directly (§4), so a system artifact carries its own thread without any goal. +Most artifacts are working memory: a plan matters for the lifetime of its goal, then survives only as historical context. Some deliverables outlive any goal — an architecture overview, an inventory of what the system is made of, a glossary of the names the project uses for those parts, decision records (ADRs), feature design records (FDRs), conventions and onboarding docs. These are **system artifacts**: ordinary artifacts whose *type* declares `scope: project` in the registry. Nothing else about them is special — same request lifecycle, same `prev` versioning, same reviews (auto-review included; a wrong architecture doc is the most expensive artifact to leave unreviewed), same conversation — messages can anchor to an artifact directly (§4), so a system artifact carries its own thread without any goal. The UI's **System section** is just "the current version of each project-scoped artifact chain" — a view the fold already computes. It collects two durability patterns with different maintenance stories: -- **Decision records** (ADR-style) are append-only and immutable: never edited, only superseded by a later record (`prev`). No staleness problem — the chain is the value. +- **Decision records** (ADR-style — the seeded `adr` for a decision about the system's shape, `fdr` for the design of one feature) are append-only and immutable: never edited, only superseded by a later record (`prev`). No staleness problem — the chain is the value. - **Living documents** (architecture, architecture-inventory, glossary, conventions) are revised across many goals as `prev`-linked versions. Durability's real cost lives here: not generation, but maintenance. Generation surfaces: diff --git a/docs/radial-json.md b/docs/radial-json.md index 5f70aa5..42a64ea 100644 --- a/docs/radial-json.md +++ b/docs/radial-json.md @@ -55,7 +55,8 @@ the directory it resolved and which rung chose it, and warns when "architecture-inventory", "glossary", "conventions", - "adr" + "adr", + "fdr" ] }, "implementer": { "artifactTypes": ["implementation"] }, @@ -482,10 +483,10 @@ This is the one field whose correct value is not knowable from this file. Artifact types are **per-space registry data**, not lexicon (design §4). A space admin writes an `artifactType` record and every client grows a button for it — no -deploy, no schema change. `radial space create` seeds seven: `plan` and +deploy, no schema change. `radial space create` seeds eight: `plan` and `implementation` (goal-scoped), plus the system types `architecture`, -`architecture-inventory`, `glossary`, `conventions`, and `adr` (project-scoped, -§8), whose current versions ride in every turn's bundle. +`architecture-inventory`, `glossary`, `conventions`, `adr`, and `fdr` +(project-scoped, §8), whose current versions ride in every turn's bundle. What no client can grow is an agent. Three things have to line up: @@ -522,7 +523,7 @@ space's agent records say, not only its own. # 2. republish that profile's agent record radiald init planner --update ✓ planner did:plc:… plan, architecture, architecture-inventory, glossary, - conventions, adr (republished) + conventions, adr, fdr (republished) ``` `radiald init` prints what each profile now publishes, so the list is visible diff --git a/docs/running-an-agent.md b/docs/running-an-agent.md index 5705fef..8644e26 100644 --- a/docs/running-an-agent.md +++ b/docs/running-an-agent.md @@ -57,7 +57,7 @@ radiald config init # or: radiald config init agent.example This prompts for the agent's atproto handle and writes `~/.config/radial/radial.json` with three profiles on that one identity: `planner` (plan plus the system types `architecture`, -`architecture-inventory`, `glossary`, `conventions`, and `adr`), +`architecture-inventory`, `glossary`, `conventions`, `adr`, and `fdr`), `implementer`, and `reviewer` (which also carries `answer`, the built-in that replies in a goal's thread). Profiles inherit the top-level `pds`, `harness`, and `models`, and each publishes its own agent record, so one identity can @@ -76,7 +76,8 @@ offer several capability sets: "architecture-inventory", "glossary", "conventions", - "adr" + "adr", + "fdr" ] }, "impl": { @@ -140,7 +141,7 @@ publishes: ```sh printf '%s\n' "$AGENT_APP_PASSWORD" | radiald init --password-stdin ✓ planner did:plc:… plan, architecture, architecture-inventory, glossary, - conventions, adr + conventions, adr, fdr ✓ implementer did:plc:… implementation ✓ reviewer did:plc:… review, answer diff --git a/packages/core/test/validation.test.mjs b/packages/core/test/validation.test.mjs index f749a9d..f75c848 100644 --- a/packages/core/test/validation.test.mjs +++ b/packages/core/test/validation.test.mjs @@ -47,6 +47,7 @@ describe('record validation', () => { ['glossary', 'project', 'living'], ['conventions', 'project', 'living'], ['adr', 'project', 'append-only'], + ['fdr', 'project', 'append-only'], ], ) for (const record of builtins) { diff --git a/packages/daemon/src/cli.ts b/packages/daemon/src/cli.ts index 791781d..777fa93 100644 --- a/packages/daemon/src/cli.ts +++ b/packages/daemon/src/cli.ts @@ -790,7 +790,7 @@ const profileOf = (uri: string): string => uri.slice(uri.lastIndexOf('/') + 1) * * - `uncovered` — the registry has a type NO agent in the space accepts. Seeded system types are the * common case: a space created today registers `architecture`, `architecture-inventory`, - * `glossary`, `conventions` and `adr`, and a + * `glossary`, `conventions`, `adr` and `fdr`, and a * `radial.json` written before them names none. * - `stale` — a profile's `artifactTypes` in the config no longer match the agent record it * published. The record is what an assignee menu reads, so editing the config alone changes diff --git a/packages/daemon/src/config.ts b/packages/daemon/src/config.ts index 446647e..a4d09d9 100644 --- a/packages/daemon/src/config.ts +++ b/packages/daemon/src/config.ts @@ -769,8 +769,8 @@ export function extraIdentities(config: RadialConfig, profiles: string[]): strin * profiles are what let an operator route a different model to reviews. * * The planner also carries the system types `space create` seeds (§8) — writing an architecture doc, - * keeping the inventory and glossary current, or distilling an ADR is the same register of work as - * planning, and a seeded type no profile publishes is a ⊕ entry whose assignee list is empty. + * keeping the inventory and glossary current, or distilling an ADR or an FDR is the same register of + * work as planning, and a seeded type no profile publishes is a ⊕ entry whose assignee list is empty. * * The `run` block is emitted with the forge already wired. It is the one setting an operator has * no way to guess is load bearing: without it the daemon cannot confirm a predecessor's pull @@ -804,6 +804,7 @@ export function buildDefaultConfig( 'glossary', 'conventions', 'adr', + 'fdr', ], }, implementer: { artifactTypes: ['implementation'] }, diff --git a/packages/daemon/test/config.test.mjs b/packages/daemon/test/config.test.mjs index b3d4ac4..9001cfd 100644 --- a/packages/daemon/test/config.test.mjs +++ b/packages/daemon/test/config.test.mjs @@ -169,7 +169,7 @@ it('scaffolds a config that round-trips straight into agent registration', async assert.deepEqual( inits.map((init) => init.artifactTypes.join()), [ - 'plan,architecture,architecture-inventory,glossary,conventions,adr', + 'plan,architecture,architecture-inventory,glossary,conventions,adr,fdr', 'implementation', // The reviewer also answers: `answer` is a built-in turn type like `review`, and an agent that // publishes neither cannot be asked for either. diff --git a/packages/lexicons/fixtures/builtins.json b/packages/lexicons/fixtures/builtins.json index 39a34e3..5640495 100644 --- a/packages/lexicons/fixtures/builtins.json +++ b/packages/lexicons/fixtures/builtins.json @@ -87,5 +87,18 @@ "scope": "project", "durability": "append-only", "createdAt": "2026-01-01T00:00:07Z" + }, + { + "$type": "com.disnetdev.radial.artifactType", + "space": {"uri": "at://did:plc:root/com.disnetdev.radial.space/space", "cid": "cid-space"}, + "name": "fdr", + "brief": "Distil the design of one feature from the supplied provenance. Write in the register of a design record — the problem, the behaviour being committed to, the design that delivers it, and the alternatives weighed against it — not as a plan with a new title. Keep it to this feature: a decision about the shape of the system belongs in an ADR. Name any earlier record this supersedes.", + "outputSpec": { + "format": "markdown", + "description": "A feature design record: status, problem, the behaviour it commits to, the design, alternatives considered, and anything left open." + }, + "scope": "project", + "durability": "append-only", + "createdAt": "2026-01-01T00:00:08Z" } ] diff --git a/packages/sidecar/README.md b/packages/sidecar/README.md index 0944aec..bf7cd83 100644 --- a/packages/sidecar/README.md +++ b/packages/sidecar/README.md @@ -84,7 +84,8 @@ requires it. `space create` also publishes the built-in artifact-type registry records: the goal-scoped `plan` and `implementation`, and the project-scoped system artifacts `architecture`, `architecture-inventory`, `glossary`, and -`conventions` (all living), and `adr` (append-only). Use `--no-builtins` only +`conventions` (all living), and `adr` and `fdr` (append-only — a decision about +the system's shape and the design of one feature). Use `--no-builtins` only if another caller will seed them. `--json` returns structured refs for scripts. diff --git a/packages/sidecar/src/commands.ts b/packages/sidecar/src/commands.ts index f84b4a4..eb3c10e 100644 --- a/packages/sidecar/src/commands.ts +++ b/packages/sidecar/src/commands.ts @@ -105,19 +105,25 @@ export interface CommandResult { } /** - * What `space create` seeds into the registry (§4). Two goal-scoped types produce the work, five + * What `space create` seeds into the registry (§4). Two goal-scoped types produce the work, six * project-scoped ones — system artifacts (§8) — hold what outlives it: the current version of each * rides in every turn's bundle, so a space that seeds none starts its agents with no orientation and - * no obvious place to put one. Durability is stated on the project-scoped five because it is what + * no obvious place to put one. Durability is stated on the project-scoped six because it is what * decides whether a successor is "v3" or "a record that supersedes this one" (§3.3); the goal-scoped * two leave it off and take the `living` default. * - * The project-scoped five are deliberately different shapes of orientation, because prose is the + * The project-scoped six are deliberately different shapes of orientation, because prose is the * wrong container for some of it. `architecture` explains and `adr` justifies, but a fresh turn also * needs to *look things up*: what the space calls a thing (`glossary`) and what the system is made of * (`architecture-inventory`). Folded into the architecture doc, both decay — a list of every queue and * component either bloats the prose or gets quietly dropped from it — so each is its own chain, with * its own drift badge and its own request button. + * + * `fdr` is `adr`'s analogue one altitude down, and the same append-only register: an ADR settles why + * the SYSTEM is shaped as it is, an FDR settles what one FEATURE does and why it is designed that + * way. They are separate chains rather than one because they are superseded by different things — a + * structural decision outlives the feature that provoked it, and a feature's design is replaced by + * the next design of that feature, not by the next decision about the system. */ const builtinTypes = [ { @@ -202,6 +208,18 @@ const builtinTypes = [ 'A decision record: status, context, the decision taken, and its consequences.', }, }, + { + name: 'fdr', + scope: 'project' as const, + durability: 'append-only' as const, + brief: + 'Distil the design of one feature from the supplied provenance. Write in the register of a design record — the problem, the behaviour being committed to, the design that delivers it, and the alternatives weighed against it — not as a plan with a new title. Keep it to this feature: a decision about the shape of the system belongs in an ADR. Name any earlier record this supersedes.', + outputSpec: { + format: 'markdown' as const, + description: + 'A feature design record: status, problem, the behaviour it commits to, the design, alternatives considered, and anything left open.', + }, + }, ] /** diff --git a/packages/sidecar/test/space-create.test.mjs b/packages/sidecar/test/space-create.test.mjs index 5e2cad5..d76af75 100644 --- a/packages/sidecar/test/space-create.test.mjs +++ b/packages/sidecar/test/space-create.test.mjs @@ -46,6 +46,7 @@ describe('space create seeds the registry', () => { ['glossary', 'project', 'living'], ['conventions', 'project', 'living'], ['adr', 'project', 'append-only'], + ['fdr', 'project', 'append-only'], ], ) // Every seed points at the space that was just written, which is what makes them registry diff --git a/packages/ui/src/lib/admin.ts b/packages/ui/src/lib/admin.ts index 1a3766a..4a33ae0 100644 --- a/packages/ui/src/lib/admin.ts +++ b/packages/ui/src/lib/admin.ts @@ -577,7 +577,7 @@ export interface SpaceDraft { /** * `radial space create`. Seeding the built-ins — `plan` and `implementation`, plus the project-scoped - * `architecture`, `architecture-inventory`, `glossary`, `conventions` and `adr` — is what + * `architecture`, `architecture-inventory`, `glossary`, `conventions`, `adr` and `fdr` — is what * `--no-builtins` turns off and this never does: a * space with an empty registry has a ⊕ menu with nothing in it and a System section that can never * fill, and "add your own types" reads very differently on a screen that already has some than on one diff --git a/packages/ui/src/lib/components/Glyph.svelte b/packages/ui/src/lib/components/Glyph.svelte index 8a4a9d2..8b99a64 100644 --- a/packages/ui/src/lib/components/Glyph.svelte +++ b/packages/ui/src/lib/components/Glyph.svelte @@ -14,6 +14,9 @@ review: 'review', 'security-review': 'shield', adr: 'adr', + // Kin to `adr` in register, deliberately not in shape: the two sit in the same System list, and + // a decision and a feature design that draw alike are two rows a person has to read to tell apart. + fdr: 'fdr', architecture: 'arch', conventions: 'book', } @@ -43,6 +46,9 @@ {:else if glyph === 'adr'} + {:else if glyph === 'fdr'} + + {:else if glyph === 'arch'} diff --git a/packages/ui/src/lib/requests.ts b/packages/ui/src/lib/requests.ts index 0ce33ed..91afd77 100644 --- a/packages/ui/src/lib/requests.ts +++ b/packages/ui/src/lib/requests.ts @@ -1,8 +1,8 @@ // Asking the space for an artifact. // // This is the core bet of the product as a piece of UI. Nothing in this file knows a type name: -// `plan`, `implementation`, `architecture`, `architecture-inventory`, `glossary`, `conventions` and -// `adr` are records `space create` seeds +// `plan`, `implementation`, `architecture`, `architecture-inventory`, `glossary`, `conventions`, +// `adr` and `fdr` are records `space create` seeds // into the registry and are otherwise no different from a `security-review` an admin adds tomorrow // (design §4). So the ⊕ menu, the // buttons under a landed artifact, the assignee list, and the words on the compose card are all diff --git a/packages/ui/src/lib/units.test.ts b/packages/ui/src/lib/units.test.ts index b110e2a..0b294db 100644 --- a/packages/ui/src/lib/units.test.ts +++ b/packages/ui/src/lib/units.test.ts @@ -21,6 +21,7 @@ import { requestState, unitActorDid, unitBadges, + typeLabel, unitSearchText, unitSummary, versionTitle, @@ -39,6 +40,17 @@ const systemUnits = unitsOf(space.index, project) // 0004 does not make an untitled `current` anywhere in System. const goalUnits = space.index.goals.flatMap((goal) => unitsOf(space.index, goal)) +describe('typeLabel', () => { + it('capitalises the seeded acronyms and leaves every other registry name alone', () => { + // Typography, not semantics: the two seeded record types are acronyms when spoken, and a name the + // list has never heard of — a type an admin adds tomorrow — still has to draw as itself. + expect(typeLabel('adr')).toBe('ADR') + expect(typeLabel('fdr')).toBe('FDR') + expect(typeLabel('architecture-inventory')).toBe('architecture-inventory') + expect(typeLabel('security-review')).toBe('security-review') + }) +}) + describe('versionTitle', () => { it('answers per version, so a renamed document keeps each version’s own name', () => { const architecture = systemUnits.find((unit) => unit.type === 'architecture') diff --git a/packages/ui/src/lib/units.ts b/packages/ui/src/lib/units.ts index db9ecc2..773251f 100644 --- a/packages/ui/src/lib/units.ts +++ b/packages/ui/src/lib/units.ts @@ -133,10 +133,13 @@ export const isGoalView = (view: GoalView | ProjectView): view is GoalView => /** * A registry name as it is written in prose. Registry names are lowercase identifiers and are shown - * as-is; `adr` is the one that is an acronym when spoken, so it is capitalised. This is typography, - * not semantics — nothing anywhere decides what a type *means* from its name (§3.3). + * as-is; `adr` and `fdr` are the ones that are acronyms when spoken, so they are capitalised. This is + * typography, not semantics — nothing anywhere decides what a type *means* from its name (§3.3), and + * a name absent from this list still draws its row. */ -export const typeLabel = (name: string): string => (name === 'adr' ? 'ADR' : name) +const SPOKEN_ACRONYMS = new Set(['adr', 'fdr']) +export const typeLabel = (name: string): string => + SPOKEN_ACRONYMS.has(name) ? name.toUpperCase() : name /** * Where a row hangs off, as the row says it: the goal's title, or `System · `. diff --git a/packages/ui/src/lib/write.test.ts b/packages/ui/src/lib/write.test.ts index ab1b169..b3d5fe8 100644 --- a/packages/ui/src/lib/write.test.ts +++ b/packages/ui/src/lib/write.test.ts @@ -1011,7 +1011,7 @@ describe('creating a space from a browser that has never opened one', () => { // need the space transport it happens to be able to borrow here. const result = await createSpace(spaceArgs({ name: 'Second', description: 'A space of its own.' })) expect(parseAtUri(result.primary.uri).collection).toBe(COLLECTIONS.space) - expect(result.refs.seededArtifactTypes).toHaveLength(7) + expect(result.refs.seededArtifactTypes).toHaveLength(8) // Opened by URI, over `listRecords`, exactly as a space somebody handed you would be — nothing is // carried across from the write, so if this tab can read it so can everyone else. @@ -1028,6 +1028,7 @@ describe('creating a space from a browser that has never opened one', () => { 'architecture', 'architecture-inventory', 'conventions', + 'fdr', 'glossary', 'implementation', 'plan',