Story 2288: a section kind is a ROLE, never a TOPIC

WHY. The first live UAT of EPIC-2270 coined these two kinds into the staging catalog: lack of evidence of AI use and misapplication of AI case law. Both are about one case. Neither can ever match a second document. The catalog's entire value is cross-document recall — a case-specific kind fills the coinage caps, defeats dedupe, and rots the vocabulary into one-use labels. RFD-DOC-004 D26 AMENDMENT (human, 2026-07-12) rules the kind names the section's function in the genre ("prayer", "lack of evidence"); the case's own subject matter (parties, technology, dates) belongs in heading_path/summary. Blake's coinage prompt asks nicely (the pramaan-agents sibling). This half is the lint that refuses.

Where it lives: the existing lint, extended — not a second validator

Story-2276 already put a lint at the write door: label_is_instruction_shaped(), called by coin_section_kind() in src/domains/section_kind/service.py. That is the ONLY door — the broker's propose_section_kind tool is the sole caller, and segmentation routes every proposed label through it. So generality goes in the same function, behind the same door. There is no parallel validator and no new call site.

lint_section_kind_label(label, matter_vocabulary=...) -> LabelRefusal | None

  1. instruction_shaped         (Story-2276, unchanged)
  2. too_many_content_tokens    <- NEW
  3. proper_noun_density        <- NEW
  4. matter_vocabulary_overlap  <- NEW

The three generality signals

RuleWhat it seesThreshold (config)
content-token cap A role name is short — "prayer", "lack of evidence". A sentence-length label is describing one document's contents, not naming a role. Stopwords (of, to, and) don't count, so a genuinely long role name like "list of dates and events" (3 content words) survives. SECTION_KIND_LABEL_MAX_CONTENT_TOKENS = 4
proper-noun density Party names, product names, acronyms, possessives — "Addictive Learning", "AI", "Learning's". These ARE the case's subject matter. Mid-label capitals count only when the label is in running case, so Preliminary Submissions (Title Case) is not mistaken for a proper noun; acronyms and possessives survive that suppression, so Misapplication Of AI Case Law still refuses. SECTION_KIND_LABEL_MAX_PROPER_NOUN_RATIO = 0.0
matter-vocabulary overlap A label built from THIS matter's own distinctive terms can never match a second document. The distinctive terms are read from the matter's identity strings (title, case ref, filename) — the capitalised words, minus stopwords, minus the genre floor. This is the rule that catches an all-lowercase, short, proper-noun-free label like addictive learning objections, which the other two rules cannot see. SECTION_KIND_LABEL_MAX_MATTER_VOCABULARY_TOKENS = 0
No new states. Every refusal returns CoinStatus = "rejected_label" — the status Story-2276 already shipped. No row is written; the section keeps its byte-proved boundary and lands on the nearest existing kind or the structural other. The only addition is CoinOutcome.refusal, which names WHICH rule refused (D9: nothing is silently dropped). That is diagnostics, surfaced as lint_rule in the tool response — not a state.

The second ruling: other is a last resort, never a bucket

The same human gate that ordered the lint also bounded it. An over-eager lint that refuses real role labels degrades the whole catalog to other — which is a worse failure than the case-specific labels it was built to stop, because now nothing has a kind at all. So calibration is deliberately conservative: when in doubt, the label coins. Rejection is for case-specific labels ONLY.

Two things enforce that bound. First, the thresholds are loose enough that every role-level kind in real Indian filings passes. Second, the genre floor: a hand-listed set of role words (prayer, synopsis, cause, title, ground, verification, chronology, corporate forms…) that never enter a matter's distinctive vocabulary. Without it, a firm acting for "Prayer Constructions Pvt Ltd" could never coin prayer.

test_label_lint_keeps_role_vocabulary_coinable is the assertion of that breadth — 21 role-level kinds, each checked twice: bare, and again against a deliberately hostile matter vocabulary ("Prayer Constructions Pvt Ltd v Addictive Learning Technology Ltd", "Synopsis Title Ground Verification Cause Facts Index"). All must coin.

The golden table

LabelVerdictRule
lack of evidenceACCEPT—
prayerACCEPT—
preliminary submissionsACCEPT—
lack of evidence of AI useREJECTproper_noun_density ("AI")
misapplication of AI case lawREJECTproper_noun_density ("AI")
opposition to Addictive Learning's applicationREJECTproper_noun_density ("Addictive", "Learning's")

All three golden rejects happen to trip the same rule, so the other two are proved red on their own planted negatives in test_each_generality_rule_refuses_on_its_own: "detailed narration of events leading to the dispute" (5 content words) trips the cap, and "addictive learning objections" — short, lowercase, no proper noun — is invisible to both other rules and is caught only by matter-vocabulary overlap.

Observed red, not just green. The rule was disarmed through its own config (SECTION_KIND_LABEL_MAX_PROPER_NOUN_RATIO=1.0, SECTION_KIND_LABEL_MAX_MATTER_VOCABULARY_TOKENS=5) and the suite re-run: the golden reject rows fail, and the end-to-end write-door test fails with ToolOutcome.AUTO_APPLIED is not ToolOutcome.SKIPPED_UNRESOLVED — i.e. the case-specific label coins again. That proves two things at once: the lint is what refuses the label, and the thresholds are genuinely config rather than decoration.

The end-to-end proof

test_case_specific_label_is_refused_at_the_write_door_and_lands_on_other runs against a real Postgres: a two-section segmentation proposing prayer and lack of evidence of AI use, plus a direct broker intent for opposition to Addictive Learning's application. Afterwards — both sections persist (2 rows: a refused label never un-sections a boundary the bytes proved), prayer is in the catalog, other is in the catalog, and no kind name contains "AI use" or "Addictive".

Operator step: the two live staging casualties

Do NOT hand-edit the staging DB. lack of evidence of AI use and misapplication of AI case law were coined on 2026-07-12, before this lint existed. They are rename/merge candidates for counsel via the existing dedupe/merge path (D26 guardrail 3: near-duplicates merge, never fork; guardrail 6: counsel may delete any kind at will, and delete = merge-into-nothing, with sections re-labelled to the nearest survivor or other). The operator step is therefore: rename lack of evidence of AI use → lack of evidence, at which point normalized-name dedupe merges it into the general kind if one already exists; and rename misapplication of AI case law → the role it actually plays (misapplication of case law, or merge into grounds). Both are counsel acts through the catalog surface, not SQL. This lint guarantees no new casualties.

Firm boundary and observability

The matter-vocabulary read is scoped by firm_id explicitly as well as by RLS — belt to the policy's braces. One matter's vocabulary can never leak into another firm's lint.

Instrumentation-exempt (ADR-124/ADR-104): the coinage path carries no manual_span and no counter today — there are no registry-listed guardrail span attributes to extend, and this Story does not add a second tracing surface to create some. The refusal is already visible where it matters: named in the broker tool response (refusal + lint_rule) and, when coinage is refused, in the section's landing on other.

Files

FileChange
src/domains/section_kind/service.pyThe lint, extended: three generality rules, the genre floor, the matter-vocabulary read, CoinOutcome.refusal.
src/shared/settings.pyThree thresholds. No literals in the rule.
src/shared/registrar/executor.pySurfaces lint_rule in the refusal response. Same outcome, same state.
tests/integration/test_section_kind_coinage.pyThe golden table, the role-vocabulary breadth floor, the per-rule planted negatives, the end-to-end degrade-to-other proof.