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_processbetter-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:
- The literal
[Translator]tag (to identify the producing role) - The advisory’s
checkvalue - The advisory’s
severityvalue - The advisory’s
resultvalue - The advisory’s
recommendationtext (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.summarizereturnsstringSentinel.flagreturnsSentinelFlag | nullGuide.suggestreturnsSuggestion[]
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.flagemits'log_only'— P4.4.1 escalation FSM may use this literal when mappingresult: 'PASS'advisories.Guide.suggestconsumesstate— A future ranking layer may sort suggestions by recent advisory frequency or operator priority.Translator.summarizelocalization — 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.