Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .beads/issues.jsonl

Large diffs are not rendered by default.

39 changes: 39 additions & 0 deletions docs/dev/m2-design/policy-ruleset.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,45 @@ This document defines the **automated policy rules** that the validation pipelin

## Rule Categories

## Ruleset Configuration & Overrides

The validation pipeline is **configurable** at runtime. The default ruleset is merged with any overrides provided via:

1. **Browser runtime overrides**
- `window.PolicyRuleset`
- `window.DirectorConfig.policyRuleset`
- `window.DirectorConfig.validationRuleset`

2. **Validator options**
- `validateProposal(proposal, { ruleset })`
- `quickValidate(proposal, { ruleset })`
- `validateProposal(proposal, { rulesetPath })` (Node-only JSON file path)

Overrides are merged on top of the default ruleset (deep merge). Only the provided keys are overridden, so you can change a single rule without redefining the entire ruleset. The resulting ruleset version is included in the validation report metadata.

Example (browser override):

```js
window.PolicyRuleset = {
version: 'v1.0.1-test',
rules: {
profanity_filter: { action: 'reject' }
}
};
```

Example (per-call override):

```js
ProposalValidator.validateProposal(proposal, {
ruleset: {
rules: {
length_limit_check: { maxLength: 1600, warnLength: 1200 }
}
}
});
```

### 1. Content Safety (Critical)

These rules check for harmful, explicit, or offensive content. **Violations trigger auto-rejection.**
Expand Down
61 changes: 57 additions & 4 deletions tests/unit/proposal-validator.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ describe('proposal-validator', () => {
expect(result.valid).toBe(true);
});

it('blocks profanity', () => {
it('sanitizes profanity in choice text', () => {
const proposal = {
choice_text: 'A damn good choice',
content: {
Expand All @@ -159,11 +159,11 @@ describe('proposal-validator', () => {
};

const result = ProposalValidator.quickValidate(proposal);
expect(result.valid).toBe(false);
expect(result.reason).toContain('safety filter');
expect(result.valid).toBe(true);
expect(result.sanitizedProposal.choice_text).toContain('[expletive]');
});

it('blocks profanity in content', () => {
it('sanitizes profanity in content', () => {
const proposal = {
choice_text: 'Clean choice',
content: {
Expand All @@ -173,6 +173,34 @@ describe('proposal-validator', () => {
};

const result = ProposalValidator.quickValidate(proposal);
expect(result.valid).toBe(true);
expect(result.sanitizedProposal.content.text).toContain('[expletive]');
});

it('fails on explicit content', () => {
const proposal = {
choice_text: 'Look closer',
content: {
text: 'The chamber was a torture chamber filled with gore.',
return_path: 'campfire'
}
};

const result = ProposalValidator.quickValidate(proposal);
expect(result.valid).toBe(false);
expect(result.reason).toContain('Explicit content');
});

it('fails on ending return path', () => {
const proposal = {
choice_text: 'End it',
content: {
text: 'The story ends here.',
return_path: 'rescue_end'
}
};

const result = ProposalValidator.quickValidate(proposal, { validReturnPaths: [] });
expect(result.valid).toBe(false);
});
});
Expand All @@ -198,6 +226,31 @@ describe('proposal-validator', () => {

expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
expect(result.report).toBeTruthy();
});

it('reports sanitization transforms', () => {
const proposal = {
choice_text: 'A damn good choice',
content: {
branch_type: 'narrative_delta',
text: 'Line 1\n\n\nLine 2 with <b>markup</b>.',
return_path: 'campfire'
},
metadata: {
confidence_score: 0.7
}
};

const result = ProposalValidator.validateProposal(proposal, {
validReturnPaths: ['campfire']
});

expect(result.valid).toBe(true);
expect(result.report.status).toBe('rejected_with_sanitization');
expect(result.report.rules.some(rule => rule.result === 'sanitized')).toBe(true);
expect(result.sanitizedProposal.content.text).not.toContain('<b>');
expect(result.sanitizedProposal.choice_text).toContain('[expletive]');
});
});
});
4 changes: 2 additions & 2 deletions web/demo/config/director-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@
},
"pacingToleranceFactor": 0.6,
"placeholderDefault": 0.3,
"riskThreshold": 0.8
}
"riskThreshold": 0.4
}
8 changes: 7 additions & 1 deletion web/demo/js/director.js
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,9 @@ async function evaluate(proposal, storyContext = {}, config = {}) {
// Step 1: validation (schema + quick safety)
try {
const validation = (typeof ProposalValidator !== 'undefined' && ProposalValidator.quickValidate)
? ProposalValidator.quickValidate(proposal)
? ProposalValidator.quickValidate(proposal, {
validReturnPaths: storyContext && storyContext.validReturnPaths
})
: { valid: true };

if (!validation || !validation.valid) {
Expand All @@ -349,6 +351,10 @@ async function evaluate(proposal, storyContext = {}, config = {}) {
emitDecisionTelemetry(result);
return result;
}

if (validation.sanitizedProposal) {
proposal = validation.sanitizedProposal;
}
} catch (e) {
const latencyMs = Math.max(0, perf.now() - start);
const result = { decision: 'reject', reason: 'Validation error', riskScore: 1.0, latencyMs, writerMs: 0, directorMs: latencyMs, totalMs: latencyMs };
Expand Down
13 changes: 12 additions & 1 deletion web/demo/js/inkrunner.js
Original file line number Diff line number Diff line change
Expand Up @@ -358,11 +358,22 @@
}

// Step 5: Validate proposal
const validation = window.ProposalValidator.quickValidate(proposal);
const validation = window.ProposalValidator.quickValidate(proposal, {
validReturnPaths,
storyThemes: lore.game_state?.story_themes || [],
narrativePhase: lore.game_state?.narrative_phase || null
});
if (!validation.valid) {
console.warn('[inkrunner] AI proposal failed validation:', validation.reason);
return null;
}

if (validation.sanitizedProposal) {
proposal.choice_text = validation.sanitizedProposal.choice_text || proposal.choice_text;
if (validation.sanitizedProposal.content?.text) {
proposal.content.text = validation.sanitizedProposal.content.text;
}
}

// Add metadata
proposal.id = window.LLMAdapter.generateProposalId();
Expand Down
Loading