Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .abcd/development/intents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ The freeze is **non-self-referential**: re-computing the hash on the frozen PRD

Customer quotes use placeholder personas from `.abcd/development/personas.json` (Alice, Bob, Carol, ... — a fixed alphabetical sequence). Selection is **by role, never by name**: the intent's audience picks the role; the role's registered name is used. Every persona is they/them.

This is also a codified abcd principle ([`principles/personas-alice-bob-carol.md`](../principles/personas-alice-bob-carol.md)): never use real names in press releases (PII), but never use generic "a hypothetical user" language (loses voice). Named personas keep quotes grounded without leaking real-world identifiers.
This is a discipline ([`disciplines/itd-79-persona-registry.md`](disciplines/itd-79-persona-registry.md), enforced by the `persona_registry` record-lint rule): never use real names in press releases (PII), but never use generic "a hypothetical user" language (loses voice). Named personas keep quotes grounded without leaking real-world identifiers.

---

Expand Down Expand Up @@ -315,7 +315,8 @@ Active discipline-kind intents (cross-cutting rules with no user moment of their
disciplines/
├── itd-1-acceptance-gates.md (## Acceptance Criteria gate on every intent + spec)
├── itd-5-prompt-quality-additions.md (prompt_version + self-improvement + injection canaries + capability_scope static)
└── itd-37-modification-grammar.md (## Modification Grammar gate on every spec — Naur's Modification axis + Ripple sub-axis)
├── itd-37-modification-grammar.md (## Modification Grammar gate on every spec — Naur's Modification axis + Ripple sub-axis)
└── itd-79-persona-registry.md (persona names from personas.json, selected by role — persona_registry lint gate)
```

See [`brief/04-surfaces/05-intent.md § 1`](../brief/04-surfaces/05-intent.md#1-intent-ids-kinds-and-lifecycle) "Discipline format" for the template (no press release; uses `## Rule` + `## Why` + `## Acceptance Criteria` instead; no `status` field).
Expand Down
54 changes: 54 additions & 0 deletions .abcd/development/intents/disciplines/itd-79-persona-registry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
id: itd-79
slug: persona-registry
kind: discipline
kind_notes: "Promoted from principles/personas-alice-bob-carol.md the day its registry lint shipped, per the principles→disciplines promotion path (enforced principle ⇒ discipline). Gate delivered by the record-lint persona_registry rule."
suggested_kind: null
spec_id: null
reclassification_history: []
severity: minor
blocked_by: []
builds_on: []
---

# Personas Come From the Registry; the Role Picks the Name

## Rule

Every persona in a user story, press-release quote, worked example, or scenario comes from the registry at `.abcd/development/personas.json` — the single source of truth for the roster. Selection is **by role, never by name**: the scenario's audience determines the role, and the role's registered name is used. Names form a fixed alphabetical sequence (Alice, Bob, Carol, Dave, …); when the roster grows, new personas continue the sequence. No invented names, no real people's names as stand-ins. Every persona is referred to as **they/them**; the real user, when referred to at all, is also they/them.

The name half of the rule is mechanically enforced: the `persona_registry` record-lint rule (blocker) flags any quote attribution of the form `said <Name>,` — Unicode-wide, including compound names — whose name is not in the registry. Attributions phrased any other way, the role-match, and the pronoun halves are review-enforced.

## Why

Persona names are load-bearing noise: a bespoke name per document makes readers wonder whether the name carries meaning, and a real-sounding name risks colliding with an actual person or project. Binding names to roles makes them carry exactly one bit of meaning — the audience — consistently across the whole corpus: Frank is always the DevOps voice, Kira always the maintainer's, Iris the product thinker's. The alphabetical sequence keeps the roster auditable and extension mechanical.

The 2026-07-08 dependency sweep demonstrated the failure mode this kills: twenty quote attributions had drifted to invented names (Theo, Mara, Priya, Fatima, "Dev") or role-mismatched roster names, hand-corrected in one pass. A registry with a lint gate prevents the drift from re-accumulating.

## What's In Scope

- The registry file (`personas.json`: roster, role hints, selection convention) as the roster's single source of truth.
- The `persona_registry` record-lint rule: quote-attribution names must be registry members; blocker severity; historical record (`superseded/`, `research/`) exempt via the standard content-drift exemption.
- Role-first selection and they/them reference as review-enforced convention wherever the mechanical gate cannot reach (role fit, pronouns, non-attribution mentions).

## What's Out of Scope

- Mechanical role-match enforcement (requires semantic judgement of the stated role against `role_hints`).
- Pronoun linting (they/them violations are a review concern; a pronoun regex cannot see referents).
- Real-name/PII detection generally — that is the itd-74 banlist family's job.

## Acceptance Criteria

> _BDD format, per the itd-1 discipline._

- **Given** an intent whose press-release quote is attributed to a name not in `personas.json`, **when** record-lint runs, **then** a `persona_registry` blocker finding names the file, line, and offending name.
- **Given** a quote attributed to a registry persona, **when** record-lint runs, **then** no persona finding is raised for that line.
- **Given** a file under an exempt path (historical record), **when** record-lint runs, **then** persona attributions in it are not flagged.
- **Given** the registry gains a persona (continuing the alphabetical sequence), **when** a new intent quotes that persona, **then** the lint passes without any lint-config change — the roster file is the only thing edited.

## References

- Registry: `.abcd/development/personas.json` (schema_version 2 — role-first selection convention).
- Gate: `persona_registry` rule in `.abcd/record-lint.json`, implemented in `internal/core/lint/persona.go`.
- Lineage: promoted from the `principles/` layer per the promotion path recorded in `principles/README.md`; decision lines of 2026-07-08 in `.abcd/work/DECISIONS.md`.
- Convention prior art: controlled-vocabulary field binding (DITA subject-scheme pattern) — see `ACKNOWLEDGEMENTS.md`.
27 changes: 0 additions & 27 deletions .abcd/development/principles/personas-alice-bob-carol.md

This file was deleted.

3 changes: 2 additions & 1 deletion .abcd/record-lint.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"links_resolve": {"enabled": true, "severity": "blocker"},
"no_brittle_line_refs": {"enabled": true, "severity": "warn"},
"directory_coverage": {"enabled": true, "severity": "warn", "exempt": []},
"intent_lifecycle": {"enabled": true, "severity": "blocker", "intents_dir": "intents"}
"intent_lifecycle": {"enabled": true, "severity": "blocker", "intents_dir": "intents"},
"persona_registry": {"enabled": true, "severity": "blocker", "registry": ".abcd/development/personas.json"}
},
"exempt_paths": [".abcd/development/research/", ".abcd/development/intents/superseded/"],
"exempt_if_status": ["superseded"]
Expand Down
4 changes: 4 additions & 0 deletions .abcd/work/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ parallel-agent merge contention bites.
registry lint ships). Coverage vocabulary (uncovered / covered-shallow /
covered-deep / orphaned / unwanted) lands as itd-53's gate reporting
language; "done" = covered-deep AND the intent's own criteria MET.
- 2026-07-08 — persona_registry lint shipped (record-lint blocker: quote
attributions must name registry personas); the personas principle promoted
to discipline itd-79 the same change, per the promotion path — first test
case of enforced-principle ⇒ discipline. principles/ file retired.
- 2026-07-08 — Persona SSOTs reconciled: `personas.json` is the single registry
(13, expandable, alphabetical sequence); selection is BY ROLE, the role's
registered name is used, never a name picked directly; all personas and the
Expand Down
3 changes: 3 additions & 0 deletions ACKNOWLEDGEMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ Ideas and methodologies that shaped the design — not code abcd depends on.
- **Citation Style Language (CSL-JSON)** — the bibliography format of the
confidential-sources design (itd-76), whose reserved `custom` field carries
the confidentiality metadata.
- **DITA subject scheme maps** — the controlled-vocabulary pattern behind the
persona registry: a field's legal values live in a dedicated registry file
and a processor flags unbound values (the `persona_registry` lint rule).
- **Diátaxis** — the four-type model behind the user documentation.
- **Domain-Driven Design (bounded contexts)** — the surface boundaries.
- **Doorstop** — the suspect-link fingerprint mechanism adopted for intent
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ called out in a **Breaking** section.

### Added

- A `persona_registry` record-lint rule: press-release quote attributions
(`said <Name>,`) must name a persona from the registry file the rule's
`registry` key points at; unknown names are blocker findings. Configured
per repo in `record-lint.json`; the historical record is skipped via the
standard content-drift exemptions.
- `abcd capture --blocked-by <iss-N,…>` records typed dependency edges on a new
issue, and `capture list` / the status board now render a derived-priority
view: unblocked issues first, then by severity, with blocked rows annotated
Expand Down
3 changes: 3 additions & 0 deletions internal/core/lint/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ type RuleConfig struct {
// Allowlist is the stray_root_docs permitted basename-stem list (upper-cased,
// extension-stripped) for top-level markdown files.
Allowlist []string `json:"allowlist"`
// Registry is the persona_registry roster file, repo-relative
// (.abcd/development/personas.json).
Registry string `json:"registry"`
}

// LoadConfig reads and decodes a record-lint config file.
Expand Down
18 changes: 16 additions & 2 deletions internal/core/lint/lint.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,9 @@ var (

// Lint runs every enabled check family against the record under repoRoot and
// returns the findings sorted deterministically. An error is returned only for
// malformed configuration (e.g. an uncompilable regexp); a walkable-but-missing
// root is skipped, not an error.
// malformed configuration (e.g. an uncompilable regexp, or a persona_registry
// roster that is missing, unparsable, or empty); a walkable-but-missing root
// is skipped, not an error.
func Lint(cfg Config, repoRoot string) ([]Finding, error) {
var findings []Finding

Expand All @@ -67,6 +68,16 @@ func Lint(cfg Config, repoRoot string) ([]Finding, error) {
gitMetaOn = gitMetaOn && gitMetaCfg.Enabled
brittleOn = brittleOn && brittleCfg.Enabled

personaCfg, personaOn := cfg.Rules["persona_registry"]
personaOn = personaOn && personaCfg.Enabled
var personaRoster map[string]bool
if personaOn {
personaRoster, err = loadPersonaRoster(repoRoot, personaCfg.Registry)
if err != nil {
return nil, err
}
}

for _, root := range cfg.Roots {
rootAbs := filepath.Join(repoRoot, root)
mdFiles, err := markdownFiles(rootAbs)
Expand All @@ -88,6 +99,9 @@ func Lint(cfg Config, repoRoot string) ([]Finding, error) {
if len(tokenChecks) > 0 && !exempt {
findings = append(findings, checkBannedTokens(rel, lines, mask, tokenChecks)...)
}
if personaOn && !exempt {
findings = append(findings, checkPersonaRegistry(rel, lines, mask, personaRoster, personaCfg)...)
}
if gitMetaOn {
findings = append(findings, checkGitMetadata(rel, lines, gitMetaCfg)...)
}
Expand Down
99 changes: 99 additions & 0 deletions internal/core/lint/lint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -557,3 +557,102 @@ func TestCleanRecordProducesNoFindings(t *testing.T) {
t.Fatalf("expected no findings on clean record, got %d: %+v", len(fs), fs)
}
}

func TestPersonaRegistry(t *testing.T) {
root := t.TempDir()
writeFile(t, root, ".abcd/development/personas.json",
`{"schema_version":2,"personas":[{"name":"Alice","role_hints":["solo founder"]},{"name":"Bob","role_hints":["staff engineer"]}]}`)
writeFile(t, root, "rec/ok.md", "# ok\n\n> \"Fine,\" said Alice, solo founder.\n")
writeFile(t, root, "rec/bad.md", "# bad\n\n> \"Nope,\" said Zorro, pirate captain.\n")
writeFile(t, root, "rec/fenced.md", "# fenced\n\n```\nsaid Zorro, pirate captain.\n```\n")
writeFile(t, root, "rec/hist/old.md", "# old\n\n> \"Old,\" said Zorro, pirate captain.\n")

cfg := Config{
Roots: []string{"rec"},
Rules: map[string]RuleConfig{
"persona_registry": {Enabled: true, Severity: "blocker", Registry: ".abcd/development/personas.json"},
},
ExemptPaths: []string{"rec/hist/"},
}
fs, err := Lint(cfg, root)
if err != nil {
t.Fatal(err)
}
if n := countRule(fs, "persona_registry"); n != 1 {
t.Fatalf("expected exactly 1 persona finding, got %d: %+v", n, fs)
}
if !hasFinding(fs, "rec/bad.md", "persona_registry", 3) {
t.Errorf("expected finding on rec/bad.md:3: %+v", fs)
}
}

func TestPersonaRegistryMissingFileErrors(t *testing.T) {
root := t.TempDir()
writeFile(t, root, "rec/ok.md", "# ok\n")
cfg := Config{
Roots: []string{"rec"},
Rules: map[string]RuleConfig{
"persona_registry": {Enabled: true, Severity: "blocker", Registry: "nope/personas.json"},
},
}
if _, err := Lint(cfg, root); err == nil {
t.Fatal("expected error for enabled rule with missing registry")
}
}

func TestPersonaRegistryEdgeCases(t *testing.T) {
root := t.TempDir()
writeFile(t, root, "reg.json",
`{"personas":[{"name":"Alice"},{"name":"Zoë"}]}`)
writeFile(t, root, "rec/multi.md",
"# m\n\n> \"A,\" said Alice, founder. \"B,\" said Zorro, pirate. And said Anne-Marie, sailor.\n")
writeFile(t, root, "rec/unicode.md", "# u\n\n> \"Fine,\" said Zoë, designer.\n")
writeFile(t, root, "rec/nocomma.md", "# n\n\n> as Zorro said before, nothing here attributes a quote.\n")

cfg := Config{
Roots: []string{"rec"},
Rules: map[string]RuleConfig{
"persona_registry": {Enabled: true, Severity: "blocker", Registry: "reg.json"},
},
}
fs, err := Lint(cfg, root)
if err != nil {
t.Fatal(err)
}
if n := countRule(fs, "persona_registry"); n != 2 {
t.Fatalf("expected 2 findings (Zorro + Anne-Marie on one line), got %d: %+v", n, fs)
}
if !hasFinding(fs, "rec/multi.md", "persona_registry", 3) {
t.Errorf("expected findings on rec/multi.md:3: %+v", fs)
}
}

func TestPersonaRegistryConfigGuards(t *testing.T) {
root := t.TempDir()
writeFile(t, root, "rec/ok.md", "# ok\n")
writeFile(t, root, "empty.json", `{"personas":[]}`)

// Enabled with empty registry path: loud error, not a directory read.
cfg := Config{Roots: []string{"rec"}, Rules: map[string]RuleConfig{
"persona_registry": {Enabled: true, Severity: "blocker"},
}}
if _, err := Lint(cfg, root); err == nil {
t.Fatal("expected error for enabled rule with empty registry path")
}

// Enabled with zero-persona roster: loud error, not whole-record flagging.
cfg.Rules["persona_registry"] = RuleConfig{Enabled: true, Severity: "blocker", Registry: "empty.json"}
if _, err := Lint(cfg, root); err == nil {
t.Fatal("expected error for zero-persona roster")
}

// Disabled rule with missing registry: no error, no findings.
cfg.Rules["persona_registry"] = RuleConfig{Enabled: false, Registry: "nope.json"}
fs, err := Lint(cfg, root)
if err != nil {
t.Fatalf("disabled rule must not touch the registry: %v", err)
}
if n := countRule(fs, "persona_registry"); n != 0 {
t.Fatalf("disabled rule produced findings: %+v", fs)
}
}
71 changes: 71 additions & 0 deletions internal/core/lint/persona.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package lint

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
)

// personaAttrRe matches a press-release quote attribution: `said <Name>,`.
// The trailing comma anchors the persona-attribution form ("said Kira, a
// maintainer") and keeps ordinary prose ("as we said above") out of scope.
// The name class is Unicode-wide (letters, marks, apostrophes, hyphens) so
// compound and non-ASCII names (O'Brien, Anne-Marie, Zoë) cannot slip past
// as silent non-matches.
var personaAttrRe = regexp.MustCompile(`\bsaid (\p{Lu}[\p{L}\p{M}'’-]*),`)

// loadPersonaRoster reads the personas registry and returns the set of
// registered names. The registry is the single source of truth for persona
// names (selection is by role; the role's registered name is used).
func loadPersonaRoster(repoRoot, rel string) (map[string]bool, error) {
if rel == "" {
return nil, fmt.Errorf("persona_registry: rule enabled but \"registry\" is not set")
}
data, err := os.ReadFile(filepath.Join(repoRoot, rel))
if err != nil {
return nil, fmt.Errorf("persona_registry: reading roster %s: %w", rel, err)
}
var reg struct {
Personas []struct {
Name string `json:"name"`
} `json:"personas"`
}
if err := json.Unmarshal(data, &reg); err != nil {
return nil, fmt.Errorf("persona_registry: parsing roster %s: %w", rel, err)
}
if len(reg.Personas) == 0 {
return nil, fmt.Errorf("persona_registry: roster %s has no personas — misconfigured registry, refusing to flag the whole record", rel)
}
roster := make(map[string]bool, len(reg.Personas))
for _, p := range reg.Personas {
roster[p.Name] = true
}
return roster, nil
}

// checkPersonaRegistry flags quote attributions whose persona name is not in
// the registry roster. Fenced-code lines are skipped via the caller's mask;
// content-exempt files (historical record) are the caller's concern.
func checkPersonaRegistry(rel string, lines []string, mask []bool, roster map[string]bool, cfg RuleConfig) []Finding {
var out []Finding
for i, line := range lines {
if mask[i] {
continue
}
for _, m := range personaAttrRe.FindAllStringSubmatch(line, -1) {
if roster[m[1]] {
continue
}
out = append(out, Finding{
File: rel,
Line: i + 1,
RuleID: "persona_registry",
Severity: cfg.Severity,
Message: fmt.Sprintf("persona %q is not in the registry (%s); personas are selected by role and use the role's registered name", m[1], cfg.Registry),
})
}
}
return out
}
Loading