Execution Packet — P4.3.1 Three Advisory Roles

1. Goal

Ship src/domains/integrity/roles.ts + src/__tests__/domains/integrity/roles.test.ts implementing three read-only advisory roles (Translator / Sentinel / Guide) per docs/contracts/p4-3-1-advisory-roles-contract.md.

All three gates pass: npm run build && npm run lint && npm test.

2. File plan

src/domains/integrity/roles.ts                          (~180 lines)
src/__tests__/domains/integrity/roles.test.ts           (~430 lines)
docs/audits/p4-3-1-advisory-roles-audit.md              (shipped Step 1)
docs/contracts/p4-3-1-advisory-roles-contract.md        (shipped Step 2)
docs/packets/p4-3-1-advisory-roles-packet.md            (this file)
docs/verification/p4-3-1-advisory-roles-verification.md (Step 5)

3. Implementation sketch — roles.ts

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

// Public types
export type SentinelFlag = {
  action: 'escalate_to_pi' | 'log_only';
  reason: string;
  advisory: Advisory;
};

export type Suggestion = {
  headline: string;
  advisory_refs: string[];
  rationale: string;
};

// Constants
export const SEVERITY_RANK: Readonly<Record<AdvisorySeverity, number>> = Object.freeze({
  LOW: 0,
  MED: 1,
  HIGH: 2,
});

const TRUNCATION_LIMIT = 240;
const TRUNCATION_SUFFIX = '';

// Helpers (module-private)
function truncate(s: string): string { ... }
function headlineFor(check: Advisory['check']): string { ... }
function rationaleFor(check: Advisory['check'], group: readonly Advisory[]): string { ... }

// Role classes
export class Translator {
  readonly role = 'Translator' as const;
  summarize(advisory: Advisory): string { ... }
}

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

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

4. Implementation order

  1. Skeleton — module header + imports + closed-set helper signatures.
  2. SEVERITY_RANK + truncate helper.
  3. Translator.summarize — template fill + truncation.
  4. Sentinel.flag — rank lookup + flag construction.
  5. headlineFor + rationaleFor helpers.
  6. Guide.suggest — Map-based grouping + insertion-order preservation.
  7. Test file — 14 G-groups per contract §6.
  8. Build / lint / test — iterate until all gates pass.

5. Test fixture sketch

import {
  computeDecisionHash,
  type Advisory,
} from '../../../domains/integrity/schema.js';
import {
  Guide,
  Sentinel,
  Translator,
  SEVERITY_RANK,
  type SentinelFlag,
  type Suggestion,
} from '../../../domains/integrity/roles.js';

function makeAdvisory(overrides: Partial<Advisory> = {}): Advisory {
  const role = overrides.role ?? 'Sentinel';
  const check = overrides.check ?? 'circular_logic';
  const result = overrides.result ?? 'WARN';
  const input = { fixture: 1 };
  return {
    role,
    check,
    result,
    severity: 'MED',
    evidence: [],
    recommendation: 'Investigate the cycle and break it at the weakest edge.',
    decision_hash: computeDecisionHash(role, check, input, result),
    timestamp_logical: 1n,
    ...overrides,
  };
}

6. Static scanner (G10) implementation pattern

const ROLES_SRC = readFileSync(rolesPath, 'utf8');

const FORBIDDEN_LITERAL_PATTERNS: readonly string[] = [
  'db.run',
  'db.exec',
  'db.prepare',
  'UPDATE',
  'INSERT',
  'DELETE',
  'mutate',
  'Date.now',
  'Math.random',
  'performance.now',
];

for (const pattern of FORBIDDEN_LITERAL_PATTERNS) {
  expect(ROLES_SRC).not.toContain(pattern);
}

// Verb-method regex (e.g. updateRow, updateState)
expect(ROLES_SRC).not.toMatch(/update[A-Z]/);

7. Module-exports snapshot (G11 — no Mutator)

import * as roles from '../../../domains/integrity/roles.js';
const exportedNames = Object.keys(roles).sort();
expect(exportedNames).toEqual([
  'Guide',
  'SEVERITY_RANK',
  'Sentinel',
  'Translator',
  // type-only exports are erased at runtime and do not appear here
]);
expect(exportedNames).not.toContain('Mutator');
for (const name of exportedNames) {
  expect(name).not.toMatch(/[Mm]utator/);
}

Note: TS type aliases (SentinelFlag, Suggestion) are erased from the runtime * import object; only class-declared and const-declared runtime values appear. The snapshot is therefore 4 entries.

8. Sentinel boundary table (G3)

The 9-cell truth table from the contract §6:

const cases: ReadonlyArray<[AdvisorySeverity, AdvisorySeverity, boolean]> = [
  ['LOW',  'LOW',  true],
  ['LOW',  'MED',  false],
  ['LOW',  'HIGH', false],
  ['MED',  'LOW',  true],
  ['MED',  'MED',  true],
  ['MED',  'HIGH', false],
  ['HIGH', 'LOW',  true],
  ['HIGH', 'MED',  true],
  ['HIGH', 'HIGH', true],
];
const sentinel = new Sentinel();
for (const [adv, thr, shouldFlag] of cases) {
  const advisory = makeAdvisory({ severity: adv });
  const flag = sentinel.flag(advisory, thr);
  if (shouldFlag) {
    expect(flag).not.toBeNull();
    expect(flag!.action).toBe('escalate_to_pi');
    expect(flag!.advisory).toBe(advisory);
  } else {
    expect(flag).toBeNull();
  }
}

9. Guide cardinality test (G6)

const guide = new Guide();
const advisories: Advisory[] = [
  makeAdvisory({ check: 'circular_logic',   recommendation: 'cyc-1' }),
  makeAdvisory({ check: 'circular_logic',   recommendation: 'cyc-2' }),
  makeAdvisory({ check: 'coercion_trap',    recommendation: 'coe-1' }),
  makeAdvisory({ check: 'axiom_drift',      recommendation: 'drf-1' }),
  makeAdvisory({ check: 'axiom_regression', recommendation: 'reg-1' }),
];
const out = guide.suggest({}, advisories);
expect(out).toHaveLength(4);
const checks = out.map((s) => s.headline);
expect(checks).toEqual([
  'Address circular logic',
  'Address coercion trap',
  'Address axiom drift',
  'Address axiom regression',
]);
// First suggestion (circular_logic) has 2 advisory refs
expect(out[0]!.advisory_refs).toHaveLength(2);

10. Gotchas / pitfalls

10.1. decision_hash collision across fixtures

makeAdvisory calls computeDecisionHash(role, check, input, result). If two fixtures share (role, check, input, result) they will share decision_hash. Fixtures that need distinct hashes must vary input or one of the closed enums. The Guide grouping test (G8) uses distinct input shapes to ensure advisory_refs contains distinct hashes.

10.2. Test path levels

src/__tests__/domains/integrity/roles.test.ts imports:

  • ../../../domains/integrity/schema.js (3 levels up + domain path)
  • ../../../domains/integrity/roles.js (same)

Compare against schema.test.ts line 45 (../../../domains/integrity/schema.js) — same depth. detectors/drift.test.ts is one level deeper (4 levels up).

10.3. Truncation determinism

truncate(s) must not call Math.random() or use locale-aware string length. Use s.length (UTF-16 code units) for the cap; this is deterministic across hosts (mirrors κ canonical’s locale-independence).

10.4. as const on readonly role field

readonly role = 'Translator' as const;

The as const is required to narrow the type from string to the literal 'Translator'. Without it, TS infers the field as string and the contract §I7 is violated (the field would not be a literal).

10.5. Empty advisories array

Guide.suggest({}, []) MUST return []. The Map-based grouping naturally handles this (empty iteration produces empty output), but assert explicitly in G7.

10.6. Object.freeze on SEVERITY_RANK

Belt-and-braces — Object.freeze prevents at-runtime mutation by buggy callers. The readonly modifier is compile-time only.

11. Risk surface

  • as const discipline — Forgetting as const on the readonly role field drops the literal type. Tests assert instance.role === 'Translator' (etc.) to catch this.
  • Map insertion order vs. Object.keys order — Map preserves insertion order per ES2015 spec; iterating with for ... of buckets works. Using Object.entries(buckets) on a plain object would also preserve insertion order for string keys per ES2020+, but Map is more idiomatic.
  • Pure-function discipline — The CI grep gate is the safety net. The contract requires the test suite to read roles.ts and assert zero forbidden patterns; this is the runtime invariant.

12. Estimated effort

  • Step 4 implementation: ~30 min
  • Step 5 tests + verification: ~30 min
  • Total: ~1 hour (M task — medium complexity, well-scoped surface).

13. Commit plan

# Commit Files
1 audit(p4-3-1-advisory-roles): inventory surface docs/audits/p4-3-1-advisory-roles-audit.md
2 contract(p4-3-1-advisory-roles): behavioral contract docs/contracts/p4-3-1-advisory-roles-contract.md
3 packet(p4-3-1-advisory-roles): execution plan docs/packets/p4-3-1-advisory-roles-packet.md
4 feat(p4-3-1-advisory-roles): three read-only roles src/domains/integrity/roles.ts, src/__tests__/domains/integrity/roles.test.ts
5 verify(p4-3-1-advisory-roles): test evidence docs/verification/p4-3-1-advisory-roles-verification.md

End of packet. Implementation may proceed.


Back to top

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

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