Files
core2026/docs/architecture/rewrite-constraints.md
T

3.4 KiB

Rewrite Constraint Contract

This document defines a shared constraint contract for the rewrite so both the turn daemon (in-memory) and API server (DB-backed) can evaluate constraints consistently.

Goals

  • Single source of truth for constraint logic.
  • Support full evaluation in daemon and precheck in API.
  • Explicit data requirements for batching and caching.
  • Deterministic reasons for deny vs unknown outcomes.

Types (TypeScript sketch)

export type ConstraintResult =
    | { kind: 'allow' }
    | { kind: 'deny'; reason: string; code?: string }
    | { kind: 'unknown'; missing: RequirementKey[] };

export type RequirementKey =
    | { kind: 'general'; id: number }
    | { kind: 'city'; id: number }
    | { kind: 'nation'; id: number }
    | { kind: 'destGeneral'; id: number }
    | { kind: 'destCity'; id: number }
    | { kind: 'destNation'; id: number }
    | { kind: 'arg'; key: string }
    | { kind: 'env'; key: string };

export interface ConstraintContext {
    actorId: number;
    cityId?: number;
    nationId?: number;
    destGeneralId?: number;
    destCityId?: number;
    destNationId?: number;
    args: Record<string, unknown>;
    env: Record<string, unknown>;
    mode: 'full' | 'precheck';
}

export interface StateView {
    has(req: RequirementKey): boolean;
    get(req: RequirementKey): unknown | null;
}

export interface Constraint {
    name: string;
    requires(ctx: ConstraintContext): RequirementKey[];
    test(ctx: ConstraintContext, view: StateView): ConstraintResult;
}

Evaluation Flow

  • ConstraintPlanner collects requirements across constraints.
  • StateView loads those requirements (daemon: in-memory, API: DB).
  • test() returns:
    • allow if constraint passes.
    • deny with a stable reason/code for UI.
    • unknown if required data is missing and mode === 'precheck'.
function evaluateConstraints(constraints: Constraint[], ctx: ConstraintContext, view: StateView): ConstraintResult {
    for (const constraint of constraints) {
        const missing = constraint.requires(ctx).filter((req) => !view.has(req));
        if (missing.length && ctx.mode === 'precheck') {
            return { kind: 'unknown', missing };
        }
        const result = constraint.test(ctx, view);
        if (result.kind !== 'allow') {
            return result;
        }
    }
    return { kind: 'allow' };
}

StateView Selection Boundary

The split between in-memory and DB-backed evaluation happens outside the constraint logic. A factory or loader chooses the StateView implementation based on the execution environment:

  • Turn daemon -> InMemoryStateView with a full in-memory snapshot.
  • API server -> DbStateView (or ProjectedStateView) that fetches only the required fields from the DB or precomputed projections.

This keeps constraints pure and deterministic, while the infrastructure layer decides how to satisfy requires() in each runtime.

Mapping from Legacy Flags

  • REQ_GENERAL -> { kind: 'general', id: actorId }
  • REQ_CITY -> { kind: 'city', id: cityId }
  • REQ_NATION -> { kind: 'nation', id: nationId }
  • REQ_DEST_* -> respective dest key
  • REQ_ARG -> { kind: 'arg', key: <argName> }
  • env dependencies (for example turnterm, year) -> { kind: 'env', key: 'turnterm' }

Data Projection Suggestion

  • API prechecks can rely on a small read model (for example general_summary) updated by the turn daemon; StateView selects the source per requirement.