All files / core/semantic-log/src decide.ts

100% Statements 74/74
100% Branches 6/6
100% Functions 1/1
100% Lines 74/74

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 751x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 180x 180x 180x 180x 180x 180x 180x 317x 177x 177x 177x 177x 317x 179x 179x 179x 180x 180x 180x 180x 180x 180x  
/**
 * Decision and branch rationale capture (PRD R11).
 *
 * R11 asks for a branch's *reason* to live in the record, deterministically
 * rather than as prose: the discriminator consulted, the candidate branches
 * considered, and the one taken. This module produces that rationale; the
 * record already carries a `decision` slot (T2) and the renderer already prints
 * it (T6), so all that is left is to fill the slot for the next record — which
 * the logger does, at emit time.
 *
 * Three properties are deliberate:
 *
 * 1. **Evaluation order is preserved verbatim.** `candidates` lists every branch
 *    in the order it was considered, false predicates included, so the recorded
 *    rationale can be replayed without reading source — R11's acceptance.
 * 2. **A decision never changes control flow.** Only the chosen branch's `run`
 *    is executed, and a predicate that throws is *not* swallowed: the error
 *    propagates unchanged and no rationale is recorded for a decision that never
 *    completed, because a half-evaluated branch is not a decision. Reporting
 *    `none` instead would turn a real failure into a silently recorded non-choice.
 * 3. **The rationale is ordinary retained data.** It is recorded while the
 *    record is assembled — before redaction runs — so `decision.*` is a
 *    channel a caller's `redact` patterns can withhold like any other. Attaching it after `redactRecord`
 *    would put it beyond the caller's `redact` patterns, the same bypass that
 *    was closed for the withheld bag.
 */
 
import {recordDecision} from './context.ts';
import type {Decision} from './record.ts';
 
export interface Branch<T> {
    /** Stable branch name, recorded even when the branch is not taken. */
    name: string;
    /** Predicate over `values`. Evaluated in array order. */
    when: (values: Record<string, unknown>) => boolean;
    /** Runs when this branch is chosen. */
    run: () => T;
}
 
/**
 * Evaluate `branches` in order, run the first whose `when` is true, and record
 * the rationale for the next emitted record. Returns `undefined` when no
 * branch matches — an outcome that is itself recorded as `none`.
 *
 * The rationale is left pending in the ambient context rather than emitted
 * here: `decide` is a pure branch helper that knows nothing about a logger, and
 * the record it explains may be written arbitrarily later. The next record in
 * this async scope takes it (once — see `takeDecision`); if no record follows,
 * the rationale is dropped with the scope and never surfaces. A predicate that
 * throws propagates to the caller and records nothing.
 */
export function decide<T>(
    discriminator: string,
    values: Record<string, unknown>,
    branches: ReadonlyArray<Branch<T>>,
): T | undefined {
    let chosen: string | undefined;
    let result: T | undefined;
    for (const branch of branches) {
        if (branch.when(values)) {
            chosen = branch.name;
            result = branch.run();
            break;
        }
    }
    const decision: Decision = {
        discriminator,
        candidates: branches.map(branch => branch.name),
        chosen: chosen ?? 'none',
        values,
    };
    recordDecision(decision);
    return result;
}