Skip to content

cycle/0009: PolicyResult class hierarchy#4

Merged
flyingrobots merged 2 commits into
mainfrom
cycle/0009-policy-result-classes
Apr 3, 2026
Merged

cycle/0009: PolicyResult class hierarchy#4
flyingrobots merged 2 commits into
mainfrom
cycle/0009-policy-result-classes

Conversation

@flyingrobots
Copy link
Copy Markdown
Owner

Summary

Replace PolicyResult interface with frozen runtime-backed classes:
ContentResult, OutlineResult, RefusedResult. Dispatch via
instanceof replaces string-tag switching.

Systems-Style JavaScript P1 + P3 + P7 applied to the policy layer.

  • 0 projection === "string" comparisons in policy/operations code
  • JSON wire format unchanged (frozen classes serialize identically)
  • 227 tests, lint + typecheck clean

Test plan

  • Policy tests use toBeInstanceOf instead of string checks
  • MCP/integration tests unchanged (JSON shape preserved)
  • All 227 tests pass

- ContentResult, OutlineResult, RefusedResult — frozen classes with
  constructor validation, replacing the PolicyResult interface
- evaluatePolicy returns class instances instead of plain objects
- safe-read.ts and server.ts use instanceof dispatch instead of
  string-tag switching on projection
- Policy tests use toBeInstanceOf instead of projection string checks
- MCP/integration tests unchanged (JSON wire format preserved)
- Zero projection === "string" comparisons in policy/operations code

Systems-Style JavaScript P1 (runtime truth) + P3 (behavior on type)
+ P7 (runtime dispatch) applied to the policy layer.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 3, 2026

Summary by CodeRabbit

  • Refactor

    • Policy results refactored from interface-based objects with string type checks to immutable runtime classes, accessed via instanceof checks instead of string comparisons.
    • Policy evaluation results are now frozen to prevent accidental mutations.
  • Documentation

    • Updated documentation to reflect the new policy result class structure; removed outdated backlog specifications.

Walkthrough

Refactored PolicyResult from a TypeScript interface with string projection discriminators to three frozen immutable classes (ContentResult, OutlineResult, RefusedResult), updating callers to use instanceof checks instead of string comparisons across policy evaluation, call sites, and tests.

Changes

Cohort / File(s) Summary
Documentation
docs/method/backlog/asap/CC_policy-result-classes.md, docs/method/retro/0009-policy-result-classes/retro.md
Removed planning document; added retrospective documenting the completed PolicyResult class refactor and runtime dispatch via instanceof.
Policy Type Hierarchy
src/policy/types.ts
Replaced PolicyResult interface with discriminated union of three exported classes: ContentResult, OutlineResult, RefusedResult. Each class enforces immutability via Object.freeze and constrains field presence/optionality (e.g., reasonDetail and next required only on RefusedResult).
Policy Evaluation
src/policy/evaluate.ts
Updated evaluatePolicy to instantiate result classes via constructors instead of returning plain objects. Preserved control flow and reason computation; all return paths now construct typed instances.
Call Site Updates
src/mcp/server.ts, src/operations/safe-read.ts
Replaced policy.projection === "refused" string checks with policy instanceof RefusedResult. In safe_read, next field now cloned as [...policy.next] to produce a new array in refusal receipts.
Test Updates
test/unit/policy/bans.test.ts, test/unit/policy/graftignore.test.ts, test/unit/policy/session-depth.test.ts, test/unit/policy/thresholds.test.ts
Replaced projection string assertions with expect(result).toBeInstanceOf(...) checks. Added instance-based type narrowing for refusal-specific field access (reasonDetail, next).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🧊 Classes frozen, stalwart and refined,
instanceof guards now replace the string design,
Polymorphism reborn through typed embrace,
Three sisters strong guard the policy space. ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: introducing a PolicyResult class hierarchy in cycle 0009.
Description check ✅ Passed The description is directly related to the changeset, detailing the replacement of the PolicyResult interface with frozen runtime classes and the shift to instanceof-based dispatch.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cycle/0009-policy-result-classes

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
test/unit/policy/session-depth.test.ts (1)

55-63: ⚠️ Potential issue | 🟡 Minor

Missing assertion for reason in "both triggered" test case.

The comment explicitly states "The tighter constraint wins: SESSION_CAP at 10 KB" but the test only asserts toBeInstanceOf(OutlineResult) without verifying result.reason === "SESSION_CAP". This was presumably the original intent and matches the other OutlineResult tests in this file that do assert the reason.

Proposed fix
     // Both static (200 > 150 lines, 15KB > 12KB) and dynamic (15KB > 10KB mid)
     // The tighter constraint wins: SESSION_CAP at 10 KB
     expect(result).toBeInstanceOf(OutlineResult);
+    expect(result.reason).toBe("SESSION_CAP");
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/unit/policy/session-depth.test.ts` around lines 55 - 63, The test
"session cap takes precedence reason when both static and dynamic triggered"
calls evaluatePolicy and only asserts instance of OutlineResult but omits
checking the result.reason; update the test to also assert that result.reason
=== "SESSION_CAP" (mirroring other tests) so the tighter constraint case is
verified, referencing the evaluatePolicy call and the
OutlineResult/result.reason fields.
src/operations/safe-read.ts (1)

60-81: 🧹 Nitpick | 🔵 Trivial

Consider explicit instanceof OutlineResult for exhaustiveness.

The implicit else at line 71 works correctly today but silently assumes anything not ContentResult or RefusedResult is OutlineResult. If the union expands in the future, TypeScript won't flag this as a missing case.

Defensive exhaustiveness pattern
   if (policy instanceof ContentResult) {
     return { ...base, content };
   }

   if (policy instanceof RefusedResult) {
     return {
       ...base,
       next: [...policy.next],
     };
   }

-  // projection === "outline"
+  // Explicit check for OutlineResult to catch future union expansion at compile time
+  if (!(policy instanceof OutlineResult)) {
+    // TypeScript will error here if union expands and this branch becomes reachable
+    const _exhaustive: never = policy;
+    throw new Error(`Unexpected policy result type: ${_exhaustive}`);
+  }
+
   const outlineResult = extractOutline(content);

This would require importing OutlineResult as well.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/operations/safe-read.ts` around lines 60 - 81, The current branch in the
projection handling assumes any non-ContentResult/RefusedResult is an
OutlineResult; make this exhaustive by explicitly checking for OutlineResult and
adding a final `throw new Error` (or assert) for unknown types so new union
members fail fast; import OutlineResult and replace the implicit else block with
an `if (policy instanceof OutlineResult) { ... }` using extractOutline, and add
a fallback that throws to surface future missing cases.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/policy/evaluate.ts`:
- Around line 105-112: The repeated pattern ...(sessionDepth !== undefined ? {
sessionDepth } : {}) used in multiple RefusedResult return paths should be
extracted to a small helper to avoid duplication; add a helper function or local
const (e.g., includeSessionDepth(sessionDepth) or optionalSessionDepth) that
returns either { sessionDepth } or {} and replace the conditional spread in each
RefusedResult construction (the lines that call new RefusedResult({...}) in
evaluate.ts) with a single spread of that helper.
- Around line 151-155: The nested ternary assigning finalReason in evaluate.ts
(using variables exceedsLines, exceedsBytes, hasSessionCap) is correct but hard
to read; replace it with an explicit if / else if chain (or add a short
clarifying comment) so the precedence is obvious: check if exceedsLines &&
!exceedsBytes -> set finalReason = "OUTLINE", else if exceedsBytes &&
hasSessionCap -> set finalReason = "SESSION_CAP", else set finalReason =
"OUTLINE" (keep the same conditions and return values but make the logic in the
function that computes finalReason explicit and readable).

---

Outside diff comments:
In `@src/operations/safe-read.ts`:
- Around line 60-81: The current branch in the projection handling assumes any
non-ContentResult/RefusedResult is an OutlineResult; make this exhaustive by
explicitly checking for OutlineResult and adding a final `throw new Error` (or
assert) for unknown types so new union members fail fast; import OutlineResult
and replace the implicit else block with an `if (policy instanceof
OutlineResult) { ... }` using extractOutline, and add a fallback that throws to
surface future missing cases.

In `@test/unit/policy/session-depth.test.ts`:
- Around line 55-63: The test "session cap takes precedence reason when both
static and dynamic triggered" calls evaluatePolicy and only asserts instance of
OutlineResult but omits checking the result.reason; update the test to also
assert that result.reason === "SESSION_CAP" (mirroring other tests) so the
tighter constraint case is verified, referencing the evaluatePolicy call and the
OutlineResult/result.reason fields.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: cdeb79fe-87c7-46b7-80c5-920951ca0b26

📥 Commits

Reviewing files that changed from the base of the PR and between 7b33def and 1649ce8.

📒 Files selected for processing (10)
  • docs/method/backlog/asap/CC_policy-result-classes.md
  • docs/method/retro/0009-policy-result-classes/retro.md
  • src/mcp/server.ts
  • src/operations/safe-read.ts
  • src/policy/evaluate.ts
  • src/policy/types.ts
  • test/unit/policy/bans.test.ts
  • test/unit/policy/graftignore.test.ts
  • test/unit/policy/session-depth.test.ts
  • test/unit/policy/thresholds.test.ts
💤 Files with no reviewable changes (1)
  • docs/method/backlog/asap/CC_policy-result-classes.md

Comment thread src/policy/evaluate.ts
Comment on lines +105 to +112
return new RefusedResult({
reason: ban.reason,
reasonDetail: ban.reasonDetail,
next: ban.next,
thresholds,
actual,
...(sessionDepth !== undefined ? { sessionDepth } : {}),
};
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Repeated conditional spread pattern—consider extracting a helper.

The pattern ...(sessionDepth !== undefined ? { sessionDepth } : {}) appears in all four return paths. This is verbose and violates DRY.

♻️ Optional: Extract a helper for optional sessionDepth inclusion
+function optionalSessionDepth(sessionDepth: SessionDepth | undefined) {
+  return sessionDepth !== undefined ? { sessionDepth } : {};
+}
+
 export function evaluatePolicy(input: PolicyInput, options?: PolicyOptions): PolicyResult {
   // ... existing code ...
   if (ban !== undefined) {
     return new RefusedResult({
       reason: ban.reason,
       reasonDetail: ban.reasonDetail,
       next: ban.next,
       thresholds,
       actual,
-      ...(sessionDepth !== undefined ? { sessionDepth } : {}),
+      ...optionalSessionDepth(sessionDepth),
     });
   }
   // Apply similarly to other return statements

Also applies to: 120-127, 143-147, 157-162

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/policy/evaluate.ts` around lines 105 - 112, The repeated pattern
...(sessionDepth !== undefined ? { sessionDepth } : {}) used in multiple
RefusedResult return paths should be extracted to a small helper to avoid
duplication; add a helper function or local const (e.g.,
includeSessionDepth(sessionDepth) or optionalSessionDepth) that returns either {
sessionDepth } or {} and replace the conditional spread in each RefusedResult
construction (the lines that call new RefusedResult({...}) in evaluate.ts) with
a single spread of that helper.

Comment thread src/policy/evaluate.ts
Comment on lines +151 to 155
const finalReason: "OUTLINE" | "SESSION_CAP" = exceedsLines && !exceedsBytes
? "OUTLINE"
: exceedsBytes && hasSessionCap
? "SESSION_CAP"
: "OUTLINE";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Nested ternary logic is correct but demands a double-take.

The cascaded ternary assigns SESSION_CAP only when bytes are exceeded and a dynamic session cap is active—otherwise it defaults to OUTLINE. This handles the edge case where both thresholds are exceeded but there's no session cap (returns OUTLINE).

Logic trace:

  • exceedsLines && !exceedsBytes"OUTLINE" (line threshold only)
  • exceedsBytes && hasSessionCap"SESSION_CAP" (dynamic cap triggered)
  • fallback → "OUTLINE" (static byte threshold exceeded, or both exceeded without session cap)

The logic is sound, but this reads like a riddle. Consider a comment clarifying the precedence or using an if/else if chain for maintainers who haven't memorized the matrix.

📖 Alternative: explicit if-else for clarity
-  const finalReason: "OUTLINE" | "SESSION_CAP" = exceedsLines && !exceedsBytes
-    ? "OUTLINE"
-    : exceedsBytes && hasSessionCap
-      ? "SESSION_CAP"
-      : "OUTLINE";
+  // Determine reason: SESSION_CAP only when the *dynamic* byte cap triggered.
+  let finalReason: "OUTLINE" | "SESSION_CAP";
+  if (exceedsBytes && hasSessionCap) {
+    finalReason = "SESSION_CAP";
+  } else {
+    finalReason = "OUTLINE";
+  }

This simplifies the logic—SESSION_CAP has a single narrow condition; everything else is OUTLINE.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const finalReason: "OUTLINE" | "SESSION_CAP" = exceedsLines && !exceedsBytes
? "OUTLINE"
: exceedsBytes && hasSessionCap
? "SESSION_CAP"
: "OUTLINE";
let finalReason: "OUTLINE" | "SESSION_CAP";
if (exceedsBytes && hasSessionCap) {
finalReason = "SESSION_CAP";
} else {
finalReason = "OUTLINE";
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/policy/evaluate.ts` around lines 151 - 155, The nested ternary assigning
finalReason in evaluate.ts (using variables exceedsLines, exceedsBytes,
hasSessionCap) is correct but hard to read; replace it with an explicit if /
else if chain (or add a short clarifying comment) so the precedence is obvious:
check if exceedsLines && !exceedsBytes -> set finalReason = "OUTLINE", else if
exceedsBytes && hasSessionCap -> set finalReason = "SESSION_CAP", else set
finalReason = "OUTLINE" (keep the same conditions and return values but make the
logic in the function that computes finalReason explicit and readable).

@flyingrobots flyingrobots merged commit a6d23b2 into main Apr 3, 2026
3 checks passed
@flyingrobots flyingrobots deleted the cycle/0009-policy-result-classes branch April 5, 2026 12:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant