agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
183 lines (182 loc) • 10.2 kB
TypeScript
/**
* findings/ledger — where the model's standings become record.
*
* Pattern: one writer for one committed key (the twin of
* `core/agent/middleware/ledger.ts · recordDecisions`).
* Role: core/ layer leaf. The dispatch loop and the route decider build
* rows with `basisRowFrom` / `standingRowsFrom`; `recordFindings` is
* the ONLY thing that writes `AgentState.findingsLedger` and emits
* the matching events, so the two call sites cannot record the same
* declaration two different ways. `foldLedger` is the read: the
* current standing per result and the conflicts among what is
* stood on, recomputed from the rows every time.
* Emits: `agentfootprint.findings.declared` (one per basis row),
* `agentfootprint.findings.standing` (one per standing row),
* since 9.104.0 `agentfootprint.findings.judged` /
* `agentfootprint.findings.judge_failed` (one per judgment /
* judgment-error row, filed by `judge.ts`), and since 9.110.0
* `agentfootprint.findings.contingent` (one per contingent row,
* filed by the two moments `contingent.ts` serves: the route
* decider's answer and the dispatch loop's call) — identities,
* enums and numbers only. Assertion values, `settles`, `line`, the
* judged state, the contingent VALUE and — on an
* `unsettled-by-absence` row — a TOOL's own words (its envelope's
* `notChecked`, `cannotCover` and `tryInstead`) live in the
* committed key under whatever redaction the run configured; an
* event stream fans out to sinks we do not control. That row
* (9.113.0, filed by the same two moments through
* `unsettled.ts · withUnsettledRows`) emits nothing of its own, as
* a conflict row emits nothing of its own.
* Observe-only negative prose rows emit
* `agentfootprint.findings.negative_claim`: bounded locations,
* checker codes and native references, never the prose or values.
*
* ## Append only, last wins, conflicts are a fold
*
* A row is never replaced. A second standing on the same result is a second
* row and the fold takes the LAST — the earlier one is quotable history, and
* two rows that disagree are NOT a conflict (the algebra sees assertions,
* never standings). A conflict row is written ONCE per key, at the write
* whose stood-on readings first disagreed, from `conflictsOf`'s output and
* never by hand; a later `ruled-out` retires a witness in the FOLD without
* touching the row, so `foldLedger(...).conflicts` is the current set and the
* conflict rows are the history of when each was first seen.
*
* ## Why the writer emits by literal
*
* The event names are the registry's `EVENT_NAMES.findings.declared` and
* `.standing`; `typedEmit` binds the literal to `AgentfootprintEventMap`, so
* a drift between this file and the registry is a compile error, exactly as
* `recordDecisions` spells `agentfootprint.middleware.decision`.
*/
import { type FindingsRequest } from './admission.js';
import { type Conflict } from '../../../integrity/assertion/conflicts.js';
import { type Assertion } from '../../../integrity/assertion/types.js';
import { type BasisRow, type ConflictRow, type DeclaredOn, type FindingsDeclaration, type FindingsLedger, type FindingsRow, type JudgmentRow, type StandingRow, type UnsettledByAbsenceRow } from './types.js';
/** The scope surface this file needs. Structurally a `TypedScope<AgentState>`. */
export interface FindingsScope {
findingsLedger?: FindingsLedger;
$emit(name: string, payload?: unknown): void;
}
/**
* One result a standing may name: an entry of the previous batch as
* `AgentState.toolResults` holds it, or a served `role: 'tool'` message read
* as one (`offer.ts · knownResults`). `toolName` is absent only for a served
* message that carries none — never invented.
*/
export interface PreviousResult {
readonly toolName?: string;
readonly result: string;
readonly toolCallId: string;
}
/** What `foldLedger` answers about a ledger. */
export interface LedgerFold {
/** The LAST standing row per result id — the MODEL's current reading of each. */
readonly standingOf: ReadonlyMap<string, StandingRow>;
/**
* The LAST judgment row per result id — the JUDGE's reading (9.104.0), a
* second source kept apart: nothing here folds it into `standingOf`, and
* `serve.ts` reads `standingOf` alone (policy A — the model's own standing
* is what is served; docs/design/2026-09-findings-ledger.md § Judge).
*/
readonly judgments: ReadonlyMap<string, JudgmentRow>;
/** The assertions of the current `fact` rows, in fold order. */
readonly asserted: readonly Assertion[];
/** `conflictsOf(asserted)` — the current conflict set, not the rows' history. */
readonly conflicts: readonly Conflict[];
/** Whether any result has a standing at all. */
readonly hasStanding: boolean;
/**
* The ruled-out standings that rest on an absence (9.113.0): the LAST
* `unsettled-by-absence` row per result, kept only while that result's
* CURRENT standing (`standingOf`) is still `ruled-out` — a later `fact`,
* `open` or `noise` moved the model's word, and a row beside the earlier
* word describes a standing that no longer stands. In the order each
* result first got a row. The piece's `unsettled by absence` section reads
* this and nothing else (`serve.ts · findingsLedgerPiece`).
*/
readonly unsettled: ReadonlyMap<string, UnsettledByAbsenceRow>;
}
/**
* Fold a ledger to its current reading. Pure; safe on a spread of the
* committed key (a TypedScope array read is a live proxy view — spread it
* into a plain array before calling anything here).
*/
export declare function foldLedger(rows: readonly FindingsRow[]): LedgerFold;
/**
* The turn stamp (honesty layers, adopted Q6): while an honesty layer is
* armed, every row the one writer files carries the conversation turn it was
* filed in. Absent → rows are filed exactly as they always were.
*/
export interface TurnStamp {
readonly turn: number;
}
/** What `appendRows` answers: the merged ledger and what the merge filed on its own. */
export interface AppendedRows {
/** The ledger to commit — `prev`, the new rows, then any new conflict rows. */
readonly ledger: FindingsRow[];
/** The conflict rows this merge filed (a key whose readings first disagreed here). */
readonly newConflicts: readonly ConflictRow[];
/** The judge's current reading per result, for the standing event's `agrees`. */
readonly judgments: ReadonlyMap<string, JudgmentRow>;
}
/**
* THE PURE HALF of the one writer: merge `rows` onto `prev`, plus a conflict
* row for every key that first disagreed at this merge. No scope, no event —
* so a subflow's output mapper can merge rows its own stages already emitted
* for (the inputs layer, `honesty/mounts.ts`) with the same fold the in-stage
* callers use. Rows other than `fact` standings never enter `conflictsOf`, so
* a merge of argument rows files no conflict.
*
* @example
* ```ts
* const { ledger } = appendRows(scope.findingsLedger ?? [], rows);
* ```
*/
export declare function appendRows(prev: readonly FindingsRow[], rows: readonly FindingsRow[], stamp?: TurnStamp): AppendedRows;
/**
* Append rows to the run's findings ledger, file a conflict row for every
* key that first disagreed at this write, and emit one event per basis or
* standing row. No-op on an empty list, so an armed agent whose model
* declared nothing never writes the key — its commit log is the one it
* always had. Callers pass basis and standing rows — each standing followed
* by the `unsettled-by-absence` row the rule files beside it, when it files
* one (`unsettled.ts · withUnsettledRows`); conflict rows are this function's
* to write. Both halves of the one writer: `appendRows` (the merge) and
* `emitRow` (the events). `stamp` is the honesty layers' turn stamp — passed
* only while a layer is armed.
*/
export declare function recordFindings(scope: FindingsScope, rows: readonly FindingsRow[], stamp?: TurnStamp): void;
/**
* THE EMIT HALF of the one writer: one event per row (identities, enums,
* counts — never a value). Exported for the inputs layer's Record stage,
* which emits inside its subflow and leaves the merge to the mount's output
* mapper (`appendRows`).
*/
export declare function emitRow(scope: FindingsScope, row: FindingsRow, newConflicts?: readonly ConflictRow[], judgments?: ReadonlyMap<string, JudgmentRow>): void;
/**
* One standing row per `previous[]` entry of a declaration. Identity comes
* from `known` by `toolCallId` — the results the run can identify, which the
* two call sites (the dispatch loop, the route decider) build with
* `offer.ts · knownResults`: the served history's tool messages plus the
* previous batch, the SAME history the offer was read from, so every id the
* offer listed resolves. An id `known` does not hold is filed with
* `unknownId: true` and no `toolName` — never resolved by position or by
* name. Assertions follow the stratum rule: `fact` → asserted,
* `open` / `ruled-out` → quoted, `noise` → none; the provenance is
* `tool:<toolCallId>`, or `artifact:<ref>` when the result was placed;
* `epoch` is never set.
*/
export declare function standingRowsFrom(known: readonly PreviousResult[], declaration: FindingsDeclaration, declaredOn: DeclaredOn, iteration: number, request?: {
readonly witness: FindingsRequest | undefined;
}): StandingRow[];
/**
* The basis row for one tool call. The declaration must carry a `basis` —
* the dispatch loop files a row only when the model declared one, and a
* caller that reaches here without one has broken that contract, which is
* reported loudly rather than defaulted (never infer a basis).
*/
export declare function basisRowFrom(tc: {
readonly id: string;
readonly name: string;
}, declaration: FindingsDeclaration, iteration: number, malformed?: number): BasisRow;