How a finished turn teaches the next one. A reviewer proposes durable memory writes; a Validator gate disposes; approved writes sediment into the MemoryStore. The keystone constraint — drawn straight from the fusion matrix and ADR-0018 — is that learning is gated, not auto-apply. This is an ADAPT of Hermes' background-review fork into the engine's ports style: no daemon thread, just three injected ports.
The driver is small and total. An empty summary or zero proposals short-circuits to an empty outcome (a no-op pass is valid); a proposer or gate error fails the whole pass closed:
// packages/hermes/src/learning/review.ts:54-70 export const reviewAndLearn = async ( summary: string, deps: ReviewDeps, ): Promise<Result<LearnOutcome, Error>> => { if (summary.trim().length === 0) return ok(emptyOutcome()); const proposed = await deps.proposer(summary); if (!proposed.ok) return proposed; // proposer error ⇒ fail closed if (proposed.value.length === 0) return ok(emptyOutcome()); const acc: OutcomeAcc = { applied: [], rejected: [], failed: [] }; for (const raw of proposed.value) { const stepErr = await processOne(raw, deps, acc); if (stepErr) return stepErr; // gate error / bad shape ⇒ fail closed } return ok({ applied: acc.applied, rejected: acc.rejected, failed: acc.failed }); };
failed ≠ rejectedThe outcome separates three fates. The distinction between rejected (the gate said no) and failed (the gate said yes but the store couldn't write) is load-bearing for observability — an over-budget write must never be mistaken for a policy refusal:
// packages/hermes/src/learning/review.ts:78-103 — processOne const parsed = reviewProposalSchema.safeParse(raw); // untrusted model output if (!parsed.success) return err(new Error(`Invalid review proposal: …`)); const proposal = parsed.data; const verdict = await deps.gate(proposal); if (!verdict.ok) return verdict; // gate ERROR ⇒ fail the pass if (!verdict.value.approved) { acc.rejected.push({ proposal, reason: verdict.value.reason }); // gate said NO return undefined; // continue the pass } const written = await deps.memory.apply(proposal.target, proposal.op); if (!written.ok) { acc.failed.push({ proposal, reason: written.error.message }); // store said NO return undefined; // still continue } acc.applied.push(proposal);
processOne returns Result<never, Error> | undefined. undefined means "this proposal is handled — continue the pass." An Err means "stop the whole pass closed." So a single proposal being rejected or store-failed does not abort the batch; only an infrastructure error (bad proposal shape, gate failure) does. The test "records a store-rejected approved write in failed" proves the first write survives while the overflow lands in failed.Until the real @alembic/coda Validator Gate (ADR-0006) wires in its own ReviewGate, the conservative default approves a proposal iff score ≥ 0.7 — the mechanical encoding of "learn only from validated wins" carried from the hermes-mini-loop:
// packages/hermes/src/learning/gate.ts:24-36 export const scoreThresholdGate = ( min: number = DEFAULT_REVIEW_SCORE_THRESHOLD, // 0.7 ): ReviewGate => { return async (proposal) => { const approved = proposal.score >= min; // boundary is INCLUSIVE const reason = approved ? `score ${proposal.score} ≥ threshold ${min}` : `score ${proposal.score} < threshold ${min} (learn only from validated wins)`; return ok({ approved, reason }); // pure & total: always ok(verdict) }; };
The gate returns ok({approved:false, …}) for a rejection — not err. The Result wrapper signals whether the gate functioned; verdict.approved carries the decision. A gate that errored (e.g. the Validator service is down) returns err and stops the pass. This separation is exactly why reviewAndLearn can tell "the policy declined this" apart from "the policy machinery broke." The test "rejects 0.69 and approves 0.70 at the default 0.7 floor" pins the inclusive boundary.
The loop adds no dedup logic of its own. Approved writes flow through the MemoryStore's existing dedup — re-proposing an entry that's already there is a no-op success (so it's counted as applied, but the store stays at one entry). That mirrors the mini-loop's ON CONFLICT DO UPDATE intent: reinforce, don't pile up duplicates. The proposal schema (learning/types.ts:62-71) validates the model's output at the boundary, including score bounded to [0,1] — a score: 1.5 from a misbehaving model is rejected before any write.
ok({approved:false, reason:'too weak'}) for a proposal. Where does it land?ok({approved:false}) → rejected[]. failed[] is reserved for proposals the gate approved but the store couldn't write. And only a gate error (err) would abort the pass.scoreThresholdGate(). The result?approved = score >= min with min = 0.7. 0.69 is below the inclusive floor, so it's rejected with the "learn only from validated wins" reason. 0.70 exactly would approve.MemoryStore's dedup instead of adding its own?applied) but doesn't grow the store — proven by the "reinforce, do not duplicate" test.score ≥ 0.7), and the real coda Validator can replace it by injection without touching this kernel.AIAgent runtime, so the same propose→gate→apply shape is reshaped into injected ports (ReviewProposer, ReviewGate, MemoryStore). The discipline is identical; the plumbing fits the engine.