Contract — P4.3.1 Three Advisory Roles (Translator / Sentinel / Guide)

1. Surface

P4.3.1 ships a single read-only adapter module:

src/domains/integrity/roles.ts

1.1. Exports (public)

Name Kind Type / Value
SentinelFlag TS type alias { action: 'escalate_to_pi' \| 'log_only'; reason: string; advisory: Advisory }
Suggestion TS type alias { headline: string; advisory_refs: string[]; rationale: string }
Translator class { readonly role: 'Translator'; summarize(advisory): string }
Sentinel class { readonly role: 'Sentinel'; flag(advisory, threshold): SentinelFlag \| null }
Guide class { readonly role: 'Guide'; suggest(state, advisories): Suggestion[] }
SEVERITY_RANK Readonly<Record<AdvisorySeverity, number>> { LOW: 0, MED: 1, HIGH: 2 } — exported for caller introspection + parity-harness reuse

The Advisory and AdvisorySeverity types are consumed verbatim from ./schema.js (P4.1.1). No re-export.

1.2. Module placement

  • Path: src/domains/integrity/roles.ts
  • Test mirror: src/__tests__/domains/integrity/roles.test.ts

roles.ts sits at the integrity-domain root (sibling of detectors/ and schema.ts); not nested under detectors/. The presentation layer is distinct from the detection layer.

2. Imports allowed / forbidden

2.1. Allowed

import type { Advisory, AdvisorySeverity } from './schema.js';

That is the only import allowed. The roles module is type-only at the import boundary.

2.2. Forbidden (compile-time guard via no-import + runtime test)

  • node:fs, node:crypto, node:os, node:child_process
  • better-sqlite3
  • ../tasks/*, ../trail/*, ../proof/*, ../reputation/*, ../consensus/*
  • ../skills/*, ../router/*, ../rules/* (the κ rule engine — not needed here)
  • Any sibling integrity module beyond ./schema.js (no detector imports — roles are consumers, not orchestrators)

3. Behavior

3.1. Translator

class Translator {
  readonly role: 'Translator';
  summarize(advisory: Advisory): string;
}

Output shape — a human-readable single-string summary. Format is deterministic but unstructured (this is the human-presentation surface).

The summary string MUST contain, in any order, substring-tested for in tests:

  1. The literal [Translator] tag (to identify the producing role)
  2. The advisory’s check value
  3. The advisory’s severity value
  4. The advisory’s result value
  5. The advisory’s recommendation text (verbatim or a truncation prefix)

The canonical template (implementation choice; tests verify substring inclusion, not exact match):

[Translator] check=<check> severity=<severity> result=<result> — <recommendation>

Where <recommendation> is truncated to 240 UTF-16 code units if longer (suffix appended on truncation). The 240-char limit is the operator-console line cap; the truncation is deterministic.

Pure function — no I/O, no mutation, no clock, no randomness.

3.2. Sentinel

class Sentinel {
  readonly role: 'Sentinel';
  flag(advisory: Advisory, severityThreshold: AdvisorySeverity): SentinelFlag | null;
}

3.2.1. Algorithm

const adv = SEVERITY_RANK[advisory.severity];
const thr = SEVERITY_RANK[severityThreshold];
if (adv >= thr) {
  return {
    action: 'escalate_to_pi',
    reason: `severity ${advisory.severity} >= threshold ${severityThreshold}`,
    advisory,
  };
}
return null;

3.2.2. Severity-rank table (exported as SEVERITY_RANK)

Severity Rank
LOW 0
MED 1
HIGH 2

Aligned with λ Reputation severity bands per R91 audit Q6. The roles module re-defines the table locally to avoid a cross-domain dependency on ../reputation/*; equivalence is enforced by AC-tested literal values.

3.2.3. Emit-vs-call rule

The flag is returned, never propagated. The Sentinel does NOT call into π, α, κ, or λ runtime surfaces. The downstream P4.4.1 escalation FSM is the consumer that decides what to do with the flag.

3.2.4. action literal — P4.3.1 always emits 'escalate_to_pi'

The union 'escalate_to_pi' | 'log_only' is exported for forward compatibility with P4.4.1 (which may emit 'log_only' for result: 'PASS' advisories). Sentinel.flag in P4.3.1 emits only 'escalate_to_pi' when the gate fires. Tests assert this.

3.2.5. advisory field on the flag

The full advisory object is attached by reference. The flag is a short-lived DTO; no defensive copy is made (the advisory is already readonly by Zod / TS contract upstream).

3.2.6. Pure function

No I/O. No mutation. The flag object is a fresh allocation; the input advisory is not mutated. The closed-enum threshold parameter is required; TypeScript rejects out-of-range values at compile time.

3.3. Guide

class Guide {
  readonly role: 'Guide';
  suggest(state: unknown, advisories: readonly Advisory[]): Suggestion[];
}

3.3.1. Algorithm (pseudocode)

function suggest(state, advisories):
  buckets: Map<AdvisoryCheck, Advisory[]> = new Map()
  for adv in advisories (preserve input order):
    if not buckets.has(adv.check):
      buckets.set(adv.check, [])
    buckets.get(adv.check).push(adv)

  suggestions: Suggestion[] = []
  for [check, group] in buckets (insertion order — preserved by Map):
    suggestions.push({
      headline: headlineFor(check),                  // see §3.3.4
      advisory_refs: group.map(a => a.decision_hash),
      rationale: rationaleFor(check, group),         // see §3.3.5
    })

  return suggestions

3.3.2. Cardinality bound

suggest(state, advisories) returns at most 4 suggestions (one per distinct value in AdvisoryCheckSchema). Tests verify this bound across random input shapes.

3.3.3. Empty input

suggest(anything, []) returns []. Empty input is not an error.

3.3.4. Headline lookup (headlineFor)

check value Headline
circular_logic 'Address circular logic'
coercion_trap 'Address coercion trap'
axiom_drift 'Address axiom drift'
axiom_regression 'Address axiom regression'

The headlines are static constants; the function is a typed switch over the closed enum. Exhaustiveness is enforced by TypeScript never-narrowing at the default branch.

3.3.5. Rationale composition (rationaleFor)

rationale = `${group.length} advisory record(s) on check=${check}. ` +
            `First recommendation: ${group[0].recommendation}`

The first recommendation is surfaced verbatim. The state parameter is ignored in v1; reserved for future ranking.

3.3.6. state: unknown

Typed unknown rather than state: never so the call site is stable for future extension. P4.3.1 ignores the parameter; tests verify two calls with different state values return identical output.

3.3.7. advisory_refs order

Preserves input order (deterministic). Mirrors κ canonical iteration order.

3.3.8. Pure function

No I/O. No mutation. The input advisories array is iterated read-only.

4. Static analysis gate (CI grep)

roles.ts MUST contain zero occurrences of the following case-sensitive substrings (the test suite reads the file and asserts):

Pattern Reason
db.run better-sqlite3 mutation API
db.exec better-sqlite3 multi-stmt mutation
db.prepare better-sqlite3 statement compile
UPDATE SQL keyword (no leading-quote restriction; substring match)
INSERT SQL keyword
DELETE SQL keyword
mutate TypeScript verb naming
Date.now Wall-clock read
Math.random Randomness
performance.now Wall-clock read

The test file uses readFileSync(roles.ts) and asserts each substring is absent via .includes(...) === false.

Note on update[A-Z] — TypeScript’s Object.entries pattern uses lowercase updateFoo style verbs. The static scanner uses a case-sensitive regex /update[A-Z]/ rather than a literal update (the word update may appear in benign contexts like JSDoc, but update<Capital> indicates a verb method). The scanner SHOULD only fail on capital-letter-after-update patterns.

Note on set (space) — explicitly tested by the regex /\bset [a-z]/ to catch state.set foo patterns without false-positives on setOf / setUp setter method names. The scanner regex must be precise.

5. Invariants

§I1 No mutator role

The module exports Translator, Sentinel, Guide, SentinelFlag, Suggestion, and SEVERITY_RANK. The test suite snapshots the module’s exported names and asserts no entry named 'Mutator', no entry matching /[Mm]utator/, exists. Per integrity.md L127: “μ does not have a ‘mutator’ role.”

§I2 Roles are pure presentation

No role calls into π, α, κ, or λ runtime surfaces. The test suite verifies this by reading the file and asserting absence of import paths containing those domain prefixes (§2.2).

§I3 Static-analysis gate is enforced at test time

The CI grep gate (§4) MUST be a Jest test in roles.test.ts. Without the test, the readonly modifier is decorative and the invariant is unenforceable in production. The test reads roles.ts and asserts zero forbidden patterns.

§I4 Determinism

For byte-identical input shapes, Translator.summarize, Sentinel.flag, and Guide.suggest return byte-identical outputs across runs. Inherited from the pure-function property (§I2) plus the κ-style iteration order (§3.3.7).

§I5 No fourth role

AdvisoryRoleSchema (P4.1.1) is closed at three values: Translator, Sentinel, Guide. P4.3.1 honors the closed set; no fourth class is added.

§I6 Output types are distinct

  • Translator.summarize returns string
  • Sentinel.flag returns SentinelFlag | null
  • Guide.suggest returns Suggestion[]

The three types are structurally non-overlapping. A caller cannot accidentally route a Translator output to a Sentinel consumer (TypeScript rejects).

§I7 readonly role field on each class

Each class exposes a readonly role field with the class-specific literal value ('Translator', 'Sentinel', 'Guide'). The field is a runtime identifier (useful for log lines + dispatch). TypeScript’s readonly modifier prevents reassignment at compile time; the field is set inline via class-field declaration (no constructor needed).

6. Tests — group structure (G1 .. G10)

Group Coverage AC
G1 Translator output substring containment AC#1, AC#4
G2 Translator determinism (×100) §I4
G3 Sentinel boundary table (LOW/MED/HIGH × LOW/MED/HIGH) AC#2, AC#11
G4 Sentinel flag shape (action, reason, advisory) AC#2, AC#6
G5 Sentinel never mutates input (deep-equality snapshot) §I2
G6 Guide cardinality bound (≤ 4) AC#3, AC#12
G7 Guide empty input → [] AC#3
G8 Guide grouping correctness (decision_hash references) AC#3
G9 Guide ignores state parameter §3.3.6
G10 Static-analysis scanner (no db.*, no Date.now, etc.) AC#8, AC#10, §I3
G11 No Mutator role exported (module-exports snapshot) AC#5, §I1
G12 Output types are structurally distinct §I6
G13 readonly role field present on each class AC#1, AC#2, AC#3, §I7
G14 SEVERITY_RANK exported with correct values §3.2.2

Total: 14 test groups.

7. Acceptance criteria (AC#1 .. AC#12)

AC# Description Test group
AC#1 Translator class exported with readonly role = 'Translator' G1, G13
AC#2 Sentinel class exported with readonly role = 'Sentinel' G3, G4, G13
AC#3 Guide class exported with readonly role = 'Guide' G6, G7, G8, G13
AC#4 Output types distinct: string / SentinelFlag \| null / Suggestion[] G1, G3, G6, G12
AC#5 No “Mutator” role exported G11
AC#6 Sentinel emits flag, never calls π/α/κ/λ directly G4, G5, G10
AC#7 Guide suggests, never executes (no mutation API) G7, G8, G10
AC#8 Static-analysis gate passes (no mutation tokens in roles.ts) G10
AC#9 npm run build && npm run lint && npm test ALL pass (gate, not a test)
AC#10 No Date.now(), Math.random(), performance.now() in roles.ts G10
AC#11 Severity-rank semantics inclusive at boundary G3
AC#12 Guide cardinality ≤ 4 (one per check) G6

8. Build / lint / test commands

npm run build && npm run lint && npm test

All three are gates per CLAUDE.md §5. Base test count at main 41226615: 3650 tests (anchor — verify with npm test baseline before adding new tests). Pass count grows by the count of new Jest assertions; no regressions in pre-existing suites.

9. Future extension points (out of scope for P4.3.1)

  • Sentinel.flag emits 'log_only' — P4.4.1 escalation FSM may use this literal when mapping result: 'PASS' advisories.
  • Guide.suggest consumes state — A future ranking layer may sort suggestions by recent advisory frequency or operator priority.
  • Translator.summarize localization — A future i18n surface may add a locale parameter. The string output stays the same shape; only the template changes.
  • MCP tool wrappers — P4.6.1 wraps each role method in an MCP tool (mu_translator_summarize, mu_sentinel_flag, mu_guide_suggest).

End of contract.


Back to top

Colibri — documentation-first MCP runtime. Apache 2.0 + Commons Clause.

This site uses Just the Docs, a documentation theme for Jekyll.