From 1e3a3042028cc9f684902c37195a056592cca874 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:41:12 +0100 Subject: [PATCH] feat: persona_registry record-lint rule; promote personas principle to discipline itd-79 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quote attributions (said , — Unicode-wide) must name a persona from the registry the rule's 'registry' key points at; unknown names are blocker findings. Historical record skipped via the standard content-drift exemptions. Guards: enabled-without-registry and zero-persona rosters error loudly instead of flagging the world. Per the promotion path (enforced principle => discipline), the personas principle becomes discipline itd-79 in the same change its gate ships; the principles/ file retires. Tests watched red first; ruthless-review FIX-FIRST items (acknowledgement, changelog, Unicode names, config guards) addressed. Assisted-by: Claude:claude-fable-5 --- .abcd/development/intents/README.md | 5 +- .../disciplines/itd-79-persona-registry.md | 54 ++++++++++ .../principles/personas-alice-bob-carol.md | 27 ----- .abcd/record-lint.json | 3 +- .abcd/work/DECISIONS.md | 4 + ACKNOWLEDGEMENTS.md | 3 + CHANGELOG.md | 5 + internal/core/lint/config.go | 3 + internal/core/lint/lint.go | 18 +++- internal/core/lint/lint_test.go | 99 +++++++++++++++++++ internal/core/lint/persona.go | 71 +++++++++++++ 11 files changed, 260 insertions(+), 32 deletions(-) create mode 100644 .abcd/development/intents/disciplines/itd-79-persona-registry.md delete mode 100644 .abcd/development/principles/personas-alice-bob-carol.md create mode 100644 internal/core/lint/persona.go diff --git a/.abcd/development/intents/README.md b/.abcd/development/intents/README.md index 3422ffc2..0b006e8d 100644 --- a/.abcd/development/intents/README.md +++ b/.abcd/development/intents/README.md @@ -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. --- @@ -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). diff --git a/.abcd/development/intents/disciplines/itd-79-persona-registry.md b/.abcd/development/intents/disciplines/itd-79-persona-registry.md new file mode 100644 index 00000000..a059e9ab --- /dev/null +++ b/.abcd/development/intents/disciplines/itd-79-persona-registry.md @@ -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 ,` — 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`. diff --git a/.abcd/development/principles/personas-alice-bob-carol.md b/.abcd/development/principles/personas-alice-bob-carol.md deleted file mode 100644 index 445c7ec6..00000000 --- a/.abcd/development/principles/personas-alice-bob-carol.md +++ /dev/null @@ -1,27 +0,0 @@ -# Personas come from the registry; the role picks the name - -Every persona in a user story, press-release quote, worked example, or scenario -comes from the registry at [`personas.json`](../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 — never he or she. - -**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. The alphabetical sequence keeps the roster auditable and -extension mechanical. - -**Applies to.** Intents (press-release quotes and scenarios), brief surface -docs, ADR examples, plans, and any user-facing prose that stages a scenario. -Enforcement: a registry-membership lint is the intended gate (names outside -the roster fail); until it exists, this principle is review-enforced. -Documents that predate the rule are corrected opportunistically when -otherwise edited, not swept. diff --git a/.abcd/record-lint.json b/.abcd/record-lint.json index 3ee9ee73..b2f0b0a5 100644 --- a/.abcd/record-lint.json +++ b/.abcd/record-lint.json @@ -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"] diff --git a/.abcd/work/DECISIONS.md b/.abcd/work/DECISIONS.md index 8680e234..7f27eb03 100644 --- a/.abcd/work/DECISIONS.md +++ b/.abcd/work/DECISIONS.md @@ -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 diff --git a/ACKNOWLEDGEMENTS.md b/ACKNOWLEDGEMENTS.md index 82171882..3bf618e1 100644 --- a/ACKNOWLEDGEMENTS.md +++ b/ACKNOWLEDGEMENTS.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 88569ec9..c0c486f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ called out in a **Breaking** section. ### Added +- A `persona_registry` record-lint rule: press-release quote attributions + (`said ,`) 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 ` 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 diff --git a/internal/core/lint/config.go b/internal/core/lint/config.go index 0e5a9ef9..7fdc4408 100644 --- a/internal/core/lint/config.go +++ b/internal/core/lint/config.go @@ -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. diff --git a/internal/core/lint/lint.go b/internal/core/lint/lint.go index 607f8779..ab2a319b 100644 --- a/internal/core/lint/lint.go +++ b/internal/core/lint/lint.go @@ -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 @@ -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) @@ -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)...) } diff --git a/internal/core/lint/lint_test.go b/internal/core/lint/lint_test.go index 92e3ffe4..fcb86e38 100644 --- a/internal/core/lint/lint_test.go +++ b/internal/core/lint/lint_test.go @@ -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) + } +} diff --git a/internal/core/lint/persona.go b/internal/core/lint/persona.go new file mode 100644 index 00000000..39e0b60c --- /dev/null +++ b/internal/core/lint/persona.go @@ -0,0 +1,71 @@ +package lint + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" +) + +// personaAttrRe matches a press-release quote attribution: `said ,`. +// 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, ®); 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 +}