Documentation source
Loops
Recursive self-improving loops — sensor, decide, critic, improve, repeat. Runs from any output type, with or without a standing goal.
## Overview
The **Loops** surface lets analysts design, orchestrate, and govern closed feedback loops directly from the web app. The execution engine is `runLoopTick` (`features/loops/lib/loop-core.ts`) — a single function that every loop kind calls. No second executor exists. (See ADR-0060.)
There are three ways a loop starts:
| Entry point | When | What owns trigger/halt |
| --- | --- | --- |
| **Session loop** (default) | Optimize one output type; iterate-until-criteria; consensus fan-out | `actions` row carrying `metadata.loop: LoopSpec` |
| **Goal loop** (standing) | Persistent OKR with cadence, spend tracking, and lifecycle | `goal` entity (`goal_system_spec` + `completion_mode`) |
| **Skill optimizer** | Score and iterate a skill, goal-less | plain `sessions` row |
A `goal` entity is **not required** to run a loop. Single-output optimization is an execution policy on the Action that produces the output — the Session loop entry point handles it without an OKR entity.
A Loop is stored on the existing `entities[entity_type_slug='goal']` row only for the Goal loop path. The word "goal" is the UI synonym for a _terminal-mode_ loop that tracks toward a strategic objective.
**The Loop OS console** lives at `/goals`:
- **List + filter** — all loops in the workspace, filterable by completion mode (`All | Goals | Loops`), with a closure-score badge per loop.
- **Discovery tab** — a sibling tab linking to Opportunity Studio; promoting a candidate creates a terminal Loop and opens its Define tab.
- **Detail page** at `/goals/[id]?tab=define|runs|updates|graph` — four tabs for a single loop.
- **Wizard** at `/goals/wizard` — creates a goal entity + wired action atomically via the `materialize_loop_from_wizard` RPC.
A loop is _closed_ only when all six closure criteria are met. Closure score (structural wiring) and achievement score (dynamic, criteria_set-driven) are kept deliberately separate.
## Key Concepts
### LoopSpec — the engine input
`LoopSpec` is the minimal config object that `runLoopTick` consumes. It is **not** a `GoalSystemSpec` variant — it is the engine's own shape, projected from the goal runtime config via an allow-list function (`goalSystemSpecToLoopSpec` in `features/loops/lib/loop-spec-projection.ts`) so new goal-only fields cannot silently leak into the engine.
```ts
// features/loops/lib/loop-core.ts
export interface LoopSpec {
criteria: GoalSystemScorecard; // the single gate-math source
verifier?: VerifierRef; // which verifier kind grades output
consensus?: { // candidate-level: N agents produce, vote picks winner
agentSlugs: string[];
strategy: "majority" | "unanimous" | "best-confidence";
};
completionMode: "terminal" | "persistent";
maxIterations?: number; // default 5 (enforced by loopSpecSchema Zod)
entityId?: string; // optional instance scope (UUID)
skills?: string[]; // producer skill slugs for iterate mode
}
```
The three spec shapes are layers, not parallels:
- **`OptimizationLoopSpec`** — the authored spec (Zod). Compiles via `compileOptimizationLoop` → `GoalLoopTemplate`. Used by the standing Goal loop path.
- **`GoalSystemSpec`** — the goal-runtime config persisted on a `goal` entity.
- **`LoopSpec`** — the engine input. Projected from `GoalSystemSpec` via the allow-list; also produced directly by `buildLoopSpecFromCriteriaSet` for Session loops.
### Verifier model — four kinds, one result shape
`resolveVerifier(ref: VerifierRef)` in `features/evals/lib/verifier.ts` is the single dispatch point. All kinds consume `CriteriaSetDimension[]` and return `RubricScoreResult`.
| Kind | Resolves to | Notes |
| --- | --- | --- |
| `llm_judge` (default) | `runRubricJudge` | Behavior-identical to every loop running before ADR-0060 |
| `deterministic` | `scoreKnowledgeArtifact` | Wires the built-in knowledge scorer into the runtime grader |
| `agent` | Dispatched `agents` row; output via `submitResponse` | Non-conforming response → `VerifierContractError`, surfaces as `waiting_human` |
| `consensus` | N verifiers vote on the score | Bounded to `maxDepth = 2` at config-validation time |
The `scored` field on `CriteriaSetDimension` routes each dimension to a verifier kind:
```ts
// features/responses/types.ts
interface CriteriaSetDimension {
// ...existing fields...
scored?: "agent" | "human" | "server" | "deterministic";
threshold?: number; // per-dimension pass bar (0–100)
}
```
A back-fill migration (`20260617000001`) sets `scored: "agent"` on all existing numeric-type dimensions so the Looping panel's warning banner does not fire permanently on pre-existing criteria sets.
> **Author surface vs. verifier layer (v1).** `resolveVerifier` supports all four kinds above, but the loop grader (`gradeGoalOutcome`) only supplies the inputs `llm_judge` needs (transcript + criteria) — it does not yet plumb a deterministic artifact/rubric or a dispatched agent payload. A loop authored with `deterministic` or `agent` would therefore throw `VerifierContractError` inside the grader and silently never grade. So the loop **author** surface (`LOOP_VERIFIER_KINDS`, the Looping panel) offers only `llm_judge` today; the other kinds re-open here once the grader supplies their inputs (`TODO(loop-verifier-inputs)`). The four-kind table above is the verifier-layer contract, not the v1 author surface.
### Two distinct consensus concepts
These are **not** the same thing:
- **`LoopSpec.consensus`** — candidate-level. N agents each produce a candidate; the vote (`pickMajority` / `pickUnanimous` / `pickBestConfidence`) picks the winner. Implemented in `features/loops/lib/consensus.ts`.
- **`VerifierRef{kind:"consensus"}`** — verification-level. N judges vote on the score.
Setting both in v1 is a validation error — the combinatorial fan-out is prohibited until v2. `resolveVerifier` enforces `maxDepth = 2` at config-validation time (not at runtime) to prevent infinite async recursion inside an Inngest step.
### Session loop session events
Four new event types appended to `session_events` by the loop branch:
| Event | When |
| --- | --- |
| `loop.iteration` | Each iteration starts |
| `loop.candidate_scored` | A candidate's score is recorded |
| `loop.consensus_resolved` | The consensus vote completes |
| `loop.skipped` | A concurrent tick found an active non-terminal session and short-circuited |
### Loop portfolio view (`LoopKind` discriminator)
`LoopKind` (`"goal" | "session"`) is stamped on every `Loop` object by `buildLoopsForTenant`. The `/goals` Loops lens unions both kinds into one ranked list. Session loops carry `sessionStatus` (running / waiting\_human / stuck / completed) and `specHint` (completionMode, hasConsensus). Goal loops surface status via `goal.progress`.
```ts
// features/loops/types.ts
export type LoopKind = "goal" | "session";
export type SessionLoopStatus = {
status: "running" | "waiting_human" | "stuck" | "completed";
iteration: number;
reason: string | null;
};
```
### One Loop primitive — `completion_mode`
**A goal is just a loop with a fixed outcome — they are the same primitive** (ADR-0046). The sole new field that distinguishes them is `completion_mode: 'terminal' | 'persistent'` on the goal entity:
- **Terminal** — loops until the acceptance scorecard passes, then halts. This is what a user would call a "goal."
- **Persistent** — re-fires on every trigger indefinitely, never halting on success. This is a continuous-improvement loop (monitoring, scoring, enrichment).
The runner branches on this one flag; all other machinery — trigger, session, grader, feedback, cost — is identical.
The `completion_mode` default is derived from the entity's `horizon` field at creation time: `always_on` → persistent, everything else → terminal. The single source of truth is `defaultCompletionModeForHorizon()` in `features/entities/lib/goal-state.ts`.
### Three state axes (reconciled)
A goal entity carries three one-concept-per-machine state axes. They must not be conflated:
| Axis | Values | Owned by |
| ------------------- | ---------------------------------------------------- | ------------------------- |
| `status` | `draft / active / at_risk / met / missed / archived` | Author + critic (scoring) |
| `goal_system_state` | `draft / ready / running / improving / stable` | Execution spine |
| `completion_mode` | `terminal / persistent` | Author (set at creation) |
`status` answers "does the objective hold?" The critic drives it toward `met` or `missed`. The loop runtime never owns it.
`goal_system_state` answers "where is the loop in its run cycle?" The runner and control-plane own this.
`completion_mode` answers "does the loop halt on acceptance?" Terminal loops consult the scorecard and halt; persistent loops never halt on success.
Valid forward transitions for each machine are documented in `features/entities/lib/goal-state.ts` (`GOAL_STATUS_TRANSITIONS`, `GOAL_SYSTEM_STATE_TRANSITIONS`).
### CLOSURE_CRITERIA (6 facts)
The six binary criteria that define a structurally closed loop, in priority order:
| # | Key | Stage | What it checks |
| --- | ------------------- | --------- | ------------------------------------------------------------------------------ |
| 1 | `hasGoal` | `goal` | `actions.goal_entity_id` is set AND the linked entity is RLS-visible |
| 2 | `hasSensor` | `sensor` | Root action has a non-manual `trigger_type` |
| 3 | `hasOutputContract` | `action` | Action declares a non-null `output_type` |
| 4 | `hasCritic` | `critic` | A `criteria_set` matches the action's output `entity_type_slug` |
| 5 | `hasImproveHook` | `improve` | `feedback` rows on one of the loop's sessions, OR a `shared_context` entry scoped to the output entity type, route critic verdicts back |
| 6 | `hasRepeatTrigger` | `repeat` | A downstream entity-event action (`entity_created` / `entity_updated` / `field_changed`) fires on the loop's output entity type — the next iteration re-entering |
`ClosureState` classification:
- `closed` — `hasGoal === true AND score === 6`
- `open` — `score === 0`
- `partial` — everything else (1–5, or 6 without a goal)
All six facts are **derived at read time** from existing platform primitives (`actions`, `criteria_sets`, `feedback`, `shared_context`, `sessions`) — there is no stored closure. The Improve and Repeat facts are computed in one batched pass by `deriveLoopWiring` (`features/loops/server/derive-loop-wiring.ts`), keyed to the loop's own root action and output entity type so an unwired loop stays `partial` rather than being marked closed by stray data elsewhere. A loop with feedback (or a shared_context entry) on its output type AND a downstream entity-event action over that type reaches all six facts and flips to `closed`.
### Goal entity type
The platform seeds a `goal` entity type (`tenant_id IS NULL`, available to every tenant, `is_system: true`). Goal entities carry: `statement`, `desired_output`, `metric_name/current/target/unit`, `lead_measures[]`, `lag_measures[]`, `by_when`, `priority`, `status`, `cadence`, `horizon`, `autonomy_level`, `acceptance_criteria_set_id`, and `completion_mode`.
Multiple actions may reference the same goal entity (shared destination). Goal CRUD uses the existing entity editor — no bespoke goal editor.
### LoopGoalSummary
Embedded in `Loop.goal` when a goal entity is linked and RLS-visible:
```ts
type LoopGoalSummary = {
entityId: string;
statement: string;
desiredOutput?: string;
priority?: "P0" | "P1" | "P2" | "P3";
status?: "draft" | "active" | "at_risk" | "met" | "missed" | "archived";
cadence?:
| "daily"
| "weekly"
| "biweekly"
| "monthly"
| "quarterly"
| "ad_hoc";
byWhen?: string;
acceptanceCriteriaSetSlug?: string;
leadMeasures?: string;
lagMeasures?: string;
completionMode?: "terminal" | "persistent";
} | null;
```
### Value model (via existing primitives)
Realized value and ROI ride existing primitives — no bespoke value engine:
| Concept | Primitive |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Value definition | A numeric cents-field on the output entity (e.g. `use_case.expected_value_cents_annual`) OR a criteria_set dimension |
| Graded run → promoted value | `gradeGoalOutcome` → `submitResponse` → `computeResponseScore` → `promote_field_value` writes realized value into `entity.content` |
| Rollup → ROI | `aggregateLoopEconomy` sums promoted cents-fields per loop; ROI = realized value ÷ attributed spend |
| Cost denominator | Primary: `ai_budget_allocations.actual_cents` (captures multi-action/delegated/chat spend); fallback: `cost_events` keyed on `driver_id = rootActionId` |
A loop that produces no scored entity (document/status output) reports `realizedValue: null` — **not zero, not a fabricated grade×estimate**. Quality-only loops show realized quality but no dollar ROI.
### Prove-value auto-pause
After each check-in fire, the execution spine evaluates a pure policy (`features/loops/lib/prove-value-policy.ts`). A loop is auto-paused when ALL are true:
- ≥ 3 iterations completed
- A value definition exists (i.e., the loop is not quality-only)
- ROI < 1 (spending more than realized value)
- Budget is exhausted or at floor
When paused: `action.status = 'paused'`, `action.pause_reason = 'low_value'`, `action.auto_paused_at` stamped. A `goal_checkin.auto_paused` session event is appended with ROI/cost/value numbers. The Updates tab surfaces the pause and a Reactivate affordance. Quality-only loops are exempt.
### Loop signature
`buildLoopSignature(rootActionId, criticId, repeatTargetId)` — SHA-256 hex prefix encoding the loop's three identity nodes. `parseLoopSignature(sig)` reverses it in O(1), used by the `/goals/[id]` page to avoid a full-tenant scan. Legacy `loop_*` signatures from Wave 1 return `null` on parse.
### Closure score vs. achievement score
- **Closure score (structural)** — fixed 6-criteria audit. Is the loop plumbed correctly? Binary per criterion. Computed by `scoreLoopClosure`.
- **Achievement score (dynamic)** — fully configurable via `criteria_sets.dimensions`. Is the loop hitting its goal? Driven by `entity_responses` scored by agents or humans.
A newly wired loop may be `closure=6, status=pursuing` (plumbed, not yet won). A successful terminal loop reaches `closure=6, status=met`.
### Goal Brief
A versioned markdown document (`Brief-Version: 1`) generated by `formatGoalBriefMarkdown(loop)`. Serves as the in-app loop handoff format and ships as copy-to-clipboard.
The goal-system control plane also exposes a versioned brief endpoint for
agents that work from the canonical goal entity:
```http
GET /api/goal-systems/{goalId}/brief
```
The response includes a `Brief-Version` header and returns both markdown and a
structured sidecar for source-backed work queues.
## How It Works
### The one engine: `runLoopTick` (ADR-0060)
`runLoopTick` in `features/loops/lib/loop-core.ts` is the single loop execution body. One invocation = one iteration = one Session. The pass/halt gate is `evaluateScorecard`. The engine:
1. Resolves skill readiness via `resolveGoalSkillReadiness` and runs ready skills to produce a candidate.
2. If `LoopSpec.consensus` is set, fans out N agents and picks the winner via the configured vote strategy.
3. Grades the candidate via `resolveVerifier(spec.verifier)` (defaults to `llm_judge`).
4. Calls `evaluateScorecard` against the criteria to decide pass/halt/continue.
5. On sub-threshold: threads the grader feedback into the next iteration's instructions via the feedback-deposit mechanism.
6. Records `loop.iteration` and `loop.candidate_scored` session events; appends `session_events`.
7. Goal-only side-effects (`recordGoalSystemProgress`, lifecycle transitions) are guarded by `if (goalId)` and never run on the goal-less Session loop path.
### Session loop: `action.metadata.loop` → `action-tick` Branch 3 (NEW — ADR-0060 Phase D)
A new branch in `features/inngest/functions/action-tick.ts` handles `actions` rows whose `metadata.loop` is a valid `LoopSpec`:
1. `actionTickRowSchema` validates `metadata.loop` through `loopSpecSchema` — a malformed config throws `NonRetriableError` loudly rather than silently no-oping.
2. Mints or continues an agent Session with `source: 'loop'` and stamps `metadata.spawned_by_queue` so the session falls under the existing `uniq_sessions_active_per_queue_entity` index. A second concurrent tick for the same `(action_id, entity_id)` hits the unique violation and continues the non-terminal session instead of minting a duplicate. If an active session already exists, the tick appends `loop.skipped` and returns.
3. Calls `runLoopTick` for each iteration until:
- **terminal** — `evaluateScorecard` passes or `maxIterations` is reached; session settles `completed`.
- **persistent** — never self-halts; re-fires on the next trigger.
4. On completion, emits a `waiting_human` + `decisionNeeded` signal into the attention queue if a promotable candidate exists.
The action's concurrency config (`limit: 1, key: event.data.actionId`) at the Inngest level serializes overlapping ticks for one action — the `loop.skipped` event is the application-level guard for the same protection.
`idx_actions_loop_enabled` (migration `20260617000002`) is a partial index on `actions ((metadata->>'loop')) WHERE metadata->>'loop' IS NOT NULL`, keeping the Loops lens union query fast.
#### How a loop action's trigger reaches Branch 3
Branch 3 consumes `ACTIONS_TICK`. Every trigger kind a loop action can carry routes to that one event — there is no second dispatch path:
- **`cron` / scheduled** — `action-cron` (`*/1`) emits `ACTIONS_TICK` for each active cron action this minute.
- **`manual`** — the admin "Run now" emits `ACTIONS_TICK` with `triggeredBy: "manual"` (bypasses the `status='active'` gate).
- **`entity_created` / `entity_updated`** — `action-dispatch` (the entity-event listener) matches the loop action by `entity_type_id` / `entity_id`, detects `metadata.loop`, and emits `ACTIONS_TICK` with `triggeredBy: "entity_event"` — instead of `triggerTask`, which would dispatch a loop-unaware generic session. This is what makes the LoopingPanel's default `entity_updated` trigger actually run the loop. The loop's entity scope lives in `metadata.loop.entityId`, so the tick targets the right record regardless of which entity fired the event.
In all three cases the loop runs through the same `runLoopBranch` → `runLoopTick` body; only the emitter of `ACTIONS_TICK` differs.
### The unified execution spine (ADR-0046 Step 5)
There is **one cron scheduler**: `action-cron` (`*/1`). Goal check-ins ride the existing `action-tick` spine as the `goal-checkin` ActionDefinition (`features/actions/library/builtins/goal-checkin.ts`). The deleted `goalLoopCron` (`*/15` scan) has no replacement — `action-cron` absorbs it.
Each fire of a goal action:
1. `action-cron` fires → `action-tick` Branch 2 resolves `actionDefinitionKey = 'goal-checkin'`.
2. `goal-checkin` definition mints one `session_type='mixed'`, `source='goal-checkin'` session.
3. Appends `goal_checkin.started` session event.
4. Calls `runGoalLoopCheckIn` (the skills orchestrator — Branch 2, deterministic, not an LLM turn).
5. The runner triggers ready skills, auto-grades produced output against the scorecard, records progress in `check_in_history`.
6. `completion_mode` gates the halt: terminal loops halt on scorecard pass; persistent loops always re-fire.
7. Appends `goal_checkin.completed` / `goal_checkin.halted` / `goal_checkin.skipped` event.
8. Evaluates the prove-value policy; appends `goal_checkin.auto_paused` and calls `pauseActionForLowValue` if triggered.
9. Terminalizes the session in `finally`.
Child skill-runner sessions link back to the check-in session via `metadata.parentSessionId`. The Runs tab stitches them for the iteration timeline.
### Loop derivation (`buildLoopsForTenant`)
Uses the request-scoped Supabase auth client. RLS on `actions` + `criteria_sets` scopes to the active tenant and workspace via URL-pinned GUCs (ADR-0003).
1. Queries `actions WHERE status='active' AND trigger_type IS NOT NULL` in parallel with `criteria_sets`.
2. For each action, matches a critic via `criteria_sets.entity_type_slugs[]` intersect on `action.entity_type.slug`.
3. PostgREST-embeds `actions.goal_entity_id → entities` to populate `Loop.goal`. `hasGoal` is visibility-aware — FK present but RLS-hidden entity yields `loop.goal = null, hasGoal = false`.
4. Calls `aggregateLoopEconomy` to populate `totalTokensSpent`, `totalValueProduced`, and `roi` per loop (no hardcoded `0`s).
5. Assembles `LoopNode[]` + `LoopEdge[]` and calls `scoreLoopClosure`.
6. Returns `Loop[]` sorted by closure score descending.
### Economy aggregation (`aggregateLoopEconomy`)
`features/loops/server/aggregate-loop-economy.ts` is the single computation for per-loop spend, realized value, and ROI. It takes an admin client + an array of loop descriptors (no circular import with `build-loops.ts`).
Cost: reads `ai_budget_allocations.actual_cents` for the loop's scope (goal-scope preferred, action-scope fallback), falling back to `cost_events` by `driver_id = rootActionId` when no allocation row exists.
Realized value: sums `_cents`-suffixed integer fields from the content of each output entity whose most-recent `entity_responses` is `status='promoted'`. Reports `null` (not `0`) when no promoted entity has a cents-field — quality-only loops never get a fabricated dollar figure.
ROI: `realizedValueCents / costCents`. `null` when realized value is null or cost is zero.
### Loop materialization (`materializeLoop`)
The wizard calls the `materialize_loop_from_wizard` SECURITY DEFINER RPC atomically:
1. Creates a `goal` entity (statement + optional fields + `completion_mode`).
2. Creates one or more `actions` referencing the goal's entity ID.
3. Arms the action with a `goal-checkin` trigger config entry so `action-tick` Branch 2 picks it up on the next `action-cron` fire.
4. Sets `actions.goal_entity_id` FK and validates via the integrity trigger.
5. Returns the created action IDs + goal entity ID.
All writes happen in a single transaction. Partial failure is impossible.
### Gap detection (`detectGapsForTenant`)
Wraps `buildLoopsForTenant` with `unstable_cache` keyed by `(tenantId, max(actions.updated_at))`. Filters out closed loops and never-iterated open loops. Ranks remaining gaps by impact-of-fixing:
1. `hasGoal=false` — highest priority (free fix: link a goal entity)
2. Partial loops ranked by descending `totalTokensSpent`
Returns `LoopGap[]` with `fixHref` deep-links per gap stage.
### Console routes (ADR-0046 Step 7)
| Route | What renders |
| ------------------------- | ------------------------------------------------------------------------------- |
| `/goals` | Loop list + completion-mode filter + Discovery sibling tab |
| `/goals/wizard` | Problem → Goal → Loop wizard |
| `/goals/[id]?tab=define` | Trigger, method, acceptance scorecard (terminal only), value definition, budget |
| `/goals/[id]?tab=runs` | Check-in timeline, projected work queue, output log |
| `/goals/[id]?tab=updates` | Live agent activity, auto-pause alerts, Reactivate affordance |
| `/goals/[id]?tab=graph` | Closure graph |
| `/goals/[id]/work` | 308 → `/goals/[id]?tab=runs` |
| `/goals/[id]/outputs` | 308 → `/goals/[id]?tab=runs` |
## Config — Creating a Loop
### From the Criteria Sets tab (operator path)
The entity type's default `criteria_sets` row (Admin > Data Types > [type] > Criteria Sets) is the single config home for loops. The Looping section in that tab provides two named actions:
**"Create Output Loop"**
Opens a confirmation sheet describing what it creates and how to pause it. On confirm, calls `createOutputLoop` (`features/loops/server/author-loop.ts`), which upserts an `actions` row:
```
actions {
trigger_config: loopConfig.trigger,
target: entityTypeSlug,
metadata.loop: LoopSpec ← validated by loopSpecSchema
}
```
Idempotent: keyed by criteria-set slug, so clicking twice does not create a second action.
**"Create Standing Goal Loop"**
Opens a confirmation sheet that states the cadence and spend implications. On confirm, calls `createGoalFromTemplateKeyed(...)` to create a persistent goal entity with lifecycle tracking.
A warning banner fires when zero dimensions in the criteria set have `scored: "agent" | "deterministic"` — the loop would produce candidates with no automatic grader.
### Materialization flow
```
"Create Output Loop"
└─▶ buildLoopSpecFromCriteriaSet(criteriaSet, loopConfig): LoopSpec
└─▶ upsert actions { metadata.loop: LoopSpec }
└─▶ action-tick Branch 3 fires on next trigger
"Create Standing Goal Loop"
└─▶ createGoalFromTemplateKeyed(...)
└─▶ goal entity + goal-checkin action
└─▶ action-tick Branch 2 fires on next cron
```
Both paths use the same `runLoopTick` engine.
## Management — `/goals` Loops Lens
The `/goals` console shows **one ranked union list** of every self-improving loop regardless of kind. The goal-vs-session split is invisible when browsing:
- **Source** — `goal` entities ∪ `actions WHERE metadata->>'loop' IS NOT NULL` joined to their latest `source='loop'` session.
- **Kind chip** — Session or Goal, plus optimization / research / consensus / knowledge label derived from `specHint`.
- **Status chip** — running / waiting\_human / stuck / completed (Session loops) or active / at\_risk / met / missed (Goal loops).
- **Score trend** — from `sessions.result.candidateLedger` + `loop.candidate_scored` events.
- **Pause** — `actions.status = 'paused'` for Session loops; the existing prove-value auto-pause / completion for Goal loops.
### Promotion via the attention queue
When a Session loop produces a candidate that passes the acceptance threshold but has no autonomous-promotion grant, it emits a `waiting_human` + `decisionNeeded` signal into the existing attention queue (`reportAgentActivity` / `getAgentHandoffPacket`). Approving calls `promoteEntityLoopCandidate` (`features/loops/server/promote-entity-loop-candidate.ts`):
- Tenant-scoped UPDATE with `data.length === 0` row-count check (RLS-filtered row → error, never silent success).
- Requires either a human-approval ID or an autonomous-threshold grant.
- Audited via `appendOptEvent`.
- Generalizes `promoteSkillCandidate` from `features/skills/server/skill-optimizer.ts` — no separate code path for skills.
## API Reference
### `buildLoopsForTenant()`
```ts
import { buildLoopsForTenant } from "@/features/loops/server/build-loops";
const loops = await buildLoopsForTenant();
// Returns Loop[] with totalTokensSpent, totalValueProduced, roi populated
```
No arguments. Tenant + workspace from request-scoped GUCs. Returns `Loop[]` sorted by closure score descending.
### `buildLoopBySignature(signature: string)`
Single-loop fetch by decoded signature. Used by `/goals/[id]` page.
### `aggregateLoopEconomy(admin, descriptors)`
```ts
import { aggregateLoopEconomy } from "@/features/loops/server/aggregate-loop-economy";
const economy = await aggregateLoopEconomy(admin, [
{ loopId, rootActionId, goalEntityId, closureScore },
]);
// Returns Map<loopId, LoopEconomyRaw>
// LoopEconomyRaw: { costUsdMicros, realizedValueCents: number | null, roi: number | null }
```
Server-only. Takes an admin client and loop descriptors. `realizedValueCents` is `null` for quality-only loops, never fabricated from a quality score.
### `materializeLoop(input)`
```ts
import { materializeLoop } from "@/features/loops/server/materialize-loop";
```
Calls `materialize_loop_from_wizard` RPC. Atomically creates goal entity + action(s) + FK + trigger config arm. Requires `actions.team.update`.
### `linkGoal(input: LinkGoalInput)`
```ts
import { linkGoal } from "@/features/loops/server/link-goal";
await linkGoal({ actionId, goalEntityId }); // pass null to unlink
```
Requires `actions.team.update`. Uses `.select()` array pattern.
### `detectGapsForTenant(tenantId: string)`
```ts
import { detectGapsForTenant } from "@/features/loops/server/detect-gaps";
```
Cached. Returns `LoopGap[]` sorted by remediation priority.
### `listLoopOutputs(loopId: string)`
```ts
import { listLoopOutputs } from "@/features/loops/server/list-outputs";
```
Returns `LoopOutputItem[]` with `latestScore`, `reviewState`, and entity metadata.
### `formatGoalBriefMarkdown(loop: Loop)`
Pure function. Returns versioned markdown (`Brief-Version: 1`) for agent handoff.
### `parseLoopSignature(signature: string)`
```ts
import { parseLoopSignature } from "@/features/loops/lib/loop-signature";
const { rootActionId, criticId, repeatTargetId } = parseLoopSignature(sig);
```
O(1) decode. Returns `null` for legacy `loop_*` signatures.
### State helpers (`features/entities/lib/goal-state.ts`)
```ts
import {
canTransitionGoalStatus,
canTransitionGoalSystemState,
defaultCompletionModeForHorizon,
} from "@/features/entities/lib/goal-state";
defaultCompletionModeForHorizon("always_on"); // → "persistent"
defaultCompletionModeForHorizon("quarterly"); // → "terminal"
canTransitionGoalStatus("active", "met"); // → true
```
### `evaluateProveValue(input: ProveValueInput)`
```ts
import { evaluateProveValue } from "@/features/loops/lib/prove-value-policy";
const decision = evaluateProveValue({
iterations,
realizedValueCents,
costCents,
budgetCents,
roi,
});
// Returns { pause: boolean; roi: number | null; realizedValueCents: number | null; costCents: number }
```
Pure function. Quality-only loops (`realizedValueCents === null`) always return `{ pause: false }`.
### `resolveVerifier(ref: VerifierRef)`
```ts
import { resolveVerifier } from "@/features/evals/lib/verifier";
const verifier = resolveVerifier({ kind: "llm_judge" });
const result = await verifier({ transcript, criteria, context, tenantId });
// Returns RubricScoreResult
```
Dispatches to one of four verifier kinds. Throws `VerifierContractError` when an `agent` verifier returns a non-conforming response (not a `submitResponse`-shaped payload against the loop's criteria set). `consensus` kind enforces `maxDepth = 2` at config-validation time.
### `buildLoopSpecFromCriteriaSet(criteriaSet, loopConfig)`
```ts
import { buildLoopSpecFromCriteriaSet } from "@/features/loops/server/author-loop";
```
Derives a `LoopSpec` from a criteria set row and a loop config. Numeric dimensions are treated as `scored: "agent"` unless already set. Weights are normalized. Server-only (`"use server"`).
### `goalSystemSpecToLoopSpec(spec: GoalSystemSpec)`
```ts
import { goalSystemSpecToLoopSpec } from "@/features/loops/lib/loop-spec-projection";
```
Allow-list projection from goal runtime config to engine input. The test suite asserts that every `LoopSpec` field is sourced from a named `GoalSystemSpec` field — a new `GoalSystemSpec` field cannot silently forward into the engine.
### `promoteEntityLoopCandidate(input)`
```ts
import { promoteEntityLoopCandidate } from "@/features/loops/server/promote-entity-loop-candidate";
```
Tenant-scoped promotion of a loop candidate. Fails closed on RLS-filtered zero rows. Requires human-approval ID or autonomous-threshold grant. Audits via `appendOptEvent`.
### `buildLoopPortfolioView(loops: Loop[])`
```ts
import { buildLoopPortfolioView } from "@/features/loops/lib/loop-portfolio-view";
```
Normalizes a mixed `Loop[]` (both `kind: "goal"` and `kind: "session"`) into a ranked portfolio view for the Loops lens. Generalizes the older `buildOptimizationLoopView`.
## For Agents
### Create or configure a loop (no new MCP tool)
Agents do NOT have a dedicated loop-creation MCP tool — per `feedback_fewer_declarative_tools`. Use the existing `manageCriteriaSets` tool, which now accepts a `loopConfig` field:
```ts
// manageCriteriaSets({ action: "upsert", criteriaSetSlug, loopConfig: { ... } })
// loopConfig writes the LoopSpec to the matching actions row.
```
For the standing goal path, use `createGoalFromTemplate({ templateSlug })`. These are the only two surfaces agents need — no third tool.
### Approve a loop candidate
When the attention queue contains a `waiting_human` + `decisionNeeded` signal from a loop:
1. Read the packet via `getAgentHandoffPacket`.
2. Inspect the candidate entity and score.
3. Approve by calling `promoteEntityLoopCandidate` (requires `entities.team.update` permission) or reject by updating the session event with a rejection note.
### Link a goal to an action
There is no external `linkGoalToAction` tool. Goal/action wiring is an in-app
server action:
```ts
import { linkGoal } from "@/features/loops/server/link-goal";
await linkGoal({ actionId, goalEntityId }); // pass null to unlink
```
It requires `actions.team.update`, sets `actions.goal_entity_id`, and
invalidates the tenant gap cache.
External orchestrators should create loops through the wizard/materialization
path or operate on existing goal entities through the goal-system tools below.
### Goal-system tools
MCP-exposed tools for external orchestrators:
- `getGoalBrief` — fetches the current Goal Brief for a goal entity before an agent starts or resumes work.
- `listGoalWork` — lists ranked next-work candidates for a goal.
- `syncGoalWorkSourceItems` — publishes source-backed work items into a declared goal work source.
- `claimGoalWork`, `renewGoalWork`, `releaseGoalWork`, `closeGoalWork` — manage shared goal-work ownership and completion.
- `checkInGoalProgress`, `submitGoalArtifact`, `submitGoalProgress` — append check-ins, artifacts, and progress updates to the goal log.
### Wizard pre-fill
The wizard route accepts `?goal=<statement>` as a URL parameter. Agents can deep-link users directly into the wizard with a pre-filled goal statement.
### Goal Brief as agent handoff
`formatGoalBriefMarkdown(loop)` is the canonical markdown format for handing off a loop's context to an external agent or Claude Code session.
### Completion mode at creation
When an agent creates a loop via `materializeLoop`, it should set `completion_mode` explicitly. If omitted, the platform derives it from `horizon`: `always_on` → persistent, anything else → terminal.
## Optimization loop view (the universal lens)
An **optimization loop** is the knowledge-work analogue of software CI + code
review: name a **target** artifact, declare **criteria** (the rubric), gather
**cases / evidence**, propose **candidates** (iterations — never blind-mutating
the canonical artifact), **score** them, and **promote** what passes a gate.
Whatever is still missing becomes the **next gaps**.
This is a **typed, visual lens over existing primitives — not a new executor,
scorer, or artifact store** (see `.claude/rules/no-parallel-systems.md`). It
mirrors the pure-deriver shape of `computeKnowledgeLoopProgress` /
`buildReadinessScorecard`: a caller projects an existing primitive into an
`OptimizationLoopInput`, and `buildOptimizationLoopView()` normalizes it into a
renderable view with honest per-stage coverage. No data is invented — an
unconfigured stage reads as `empty`, a configured-but-unmeasured stage as
`partial`, a complete one as `ready`. Coverage counts the **six setup stages**
(Target → Promotion); **next gaps** is the derived output, not a counted stage.
| Symbol | Location | Role |
| ------------------------------- | --------------------------------------------------------- | ----------------------------------------------------------------- |
| `OptimizationLoopView` / `…Input`| `features/loops/lib/optimization-loop-view.ts` | The six-stage view model + derived next-gaps + input contract |
| `buildOptimizationLoopView()` | `features/loops/lib/optimization-loop-view.ts` | Pure normalizer: input → view with coverage + derived next-gaps |
| `OptimizationLoopSummary` | `features/loops/components/optimization-loop-summary.tsx` | Reusable card — consumes a view, renders the stages + next gaps |
| `deriveSkillOptimizationLoop()` | `features/skills/lib/skill-optimization-view.ts` | The skill projector (manifest eval contract → view) |
### Projecting any artifact
The view model is source-agnostic. Each surface writes a small **projector** that
maps its own primitive into `OptimizationLoopInput`, then renders the shared card:
- **Skill** (shipped) — `deriveSkillOptimizationLoop()` reads the manifest
`evals` contract. Rendered on the skill detail Overview tab and as an
`Evals N/6` badge in the Command Center Skills Library (gated on
`skillHasEvalContract`, so promotion/goal-loop readiness alone never implies
eval setup).
- **Entity / proposal / SOP** (projector sketch) — map a criteria set's
dimensions → `criteria`, the entity's responses → `scores`/`candidates`, and
`humanGates`/review mode → `promotion`. Register an **`optimization`
entity-detail tab** via `registerSlot(slotKey("entity-detail-tab",
"*:optimization"), …)` (mirror `register-field-dependencies-tab.ts`) that
reads `criteria_sets` + responses from the tab's entity context. Do **not**
build a second scoring view — read the same `features/responses` primitives
`ResponsesTab` already uses.
- **Goal system** (projector sketch) — map `goal_system_spec` closure criteria →
stages, `check_in_history` → candidate iterations, and the readiness gradient →
coverage. Surface the `Next gaps` stage next to `GoalProgressPanel`, reusing
`rankGapsForAttention()` rather than re-deriving gaps.
```ts
// Universal: the same builder powers a non-skill artifact.
const view = buildOptimizationLoopView({
target: { label: "Rock Hill acquisition memo", kind: "proposal" },
criteria: ["Thesis is falsifiable", "Downside quantified"],
cases: ["Comparable deal 2024", "Comparable deal 2025"],
candidates: [{ label: "Draft B", score: 84, note: "candidate" }],
scores: [{ key: "rigor", label: "Analytical rigor", value: 84, scale: [0, 100] }],
promotion: { mode: "supervised", gates: ["Partner sign-off"] },
});
// <OptimizationLoopSummary view={view} /> — identical card, different source.
```
> **Convergence note.** This is the **presentation** layer. A sibling effort may
> land a richer `OptimizationLoop` _contract_ that compiles to Goal System /
> Action / Session primitives. When it does, add a thin adapter `spec → input`;
> the component depends only on `OptimizationLoopView`, never on a single source.
## Loop lifecycle (the five-phase operational lens)
Where the optimization-loop view answers "is this loop **set up**?" (six setup
stages as a checklist), the **lifecycle lens** answers "what does the loop
**do**, as a running lifecycle?" — the five phases every agentic work loop
shares:
```
trigger → signal & context → action → eval gate → stop condition
```
1. **trigger** — what signals the loop to run (a deal goes quiet, a lead fills a
form, a cron fires, an artifact's eval regresses). _A loop that waits for you
to start it isn't a loop._
2. **signal & context** — what the agent gathers before acting (account history,
sources, golden cases, the prior iteration's eval).
3. **action** — the work the agent does (drafts, scores, routes, generates a
candidate).
4. **eval gate** — the definition of "good" plus the human/automated check before
anything ships.
5. **stop condition** — shipped, approved, or killed; the budget / kill rule that
keeps the loop from burning money forever.
These five phases are a **lens over the same `OptimizationLoopSpec`** — not a new
model. The spec already carries `evidence` (signal), `candidates` (action),
`criteria` + `promotion` (eval gate), and `stop`. The one stage that was
missing — **Stage 0, the trigger** — is now an optional `trigger` on the spec,
reusing the Actions-primitive vocabulary (`TASK_TRIGGER_TYPES`: `manual`,
`entity_created`, `entity_updated`, `field_changed`, `cron`, `webhook`) so a
materialized loop's trigger is an ordinary `actions` row, the same vocabulary the
`CLOSURE_CRITERIA` (`hasSensor` / `hasRepeatTrigger`) already reads. The validator
warns (`trigger-not-self-firing`) when a `persistent` loop declares a `manual`
trigger.
| Symbol | Location | Role |
| ----------------------------------- | ------------------------------------------------- | ----------------------------------------------------------------------------- |
| `OptimizationTriggerSchema` | `features/loops/lib/optimization-loop.ts` | Stage 0 — the optional trigger (reuses `TASK_TRIGGER_TYPES`) |
| `LoopLifecycleView` / `…Input` | `features/loops/lib/loop-lifecycle.ts` | The five-phase view model + run-state overlay + input contract |
| `buildLoopLifecycleView()` | `features/loops/lib/loop-lifecycle.ts` | Pure normalizer: input → five-phase view with honest coverage |
| `projectOptimizationLoopLifecycle()`| `features/loops/lib/loop-lifecycle.ts` | The canonical projector: any `OptimizationLoopSpec` → lifecycle view |
| `LoopLifecycleFlow` | `features/loops/components/loop-lifecycle-flow.tsx`| Reusable flow card — phases as a flow + a run-state strip |
Because knowledge loops project to an `OptimizationLoopSpec`
(`knowledgeLoopToOptimizationLoop`), **the same lifecycle lens serves revenue
loops, knowledge / research loops, and skill-optimization loops** — one taxonomy,
not a revenue-only one-off. The `LoopLifecycleFlow` card shows per-phase status,
plus a run-state strip (current **iteration**, the **eval gate**, the **stop /
budget** rule, and the **latest signal** the loop acted on) from an optional
runtime overlay supplied by a caller that has the loop's check-in history.
### Revenue loop example
`revenueReengagementExample` (`features/loops/lib/optimization-loop.examples.ts`)
is the worked revenue loop: a late-stage **deal goes quiet** (`entity_updated`
trigger) → gather **account history / last call / CRM state** → draft a
**re-engagement email** → the **deal owner approves** before it ships
(human-approval gate) → **stop** when sent or **kill** after 7 days of silence
(plus a per-deal token budget). It uses the same `OptimizationLoopSpec` contract
as the skill, proposal, and research examples.
```ts
import { projectOptimizationLoopLifecycle } from "@/features/loops/lib/loop-lifecycle";
import { revenueReengagementExample } from "@/features/loops/lib/optimization-loop.examples";
const view = projectOptimizationLoopLifecycle(revenueReengagementExample, {
currentPhaseKey: "evaluation",
iteration: 2,
maxRounds: 3,
latestSignal: "Last call 9 days ago",
spentLabel: "$2.40 of $10.00",
});
// <LoopLifecycleFlow view={view} /> — trigger → signal → action → eval → stop.
```
> **Where real revenue loops live.** This example is neutral platform demo data.
> A venture authors its own revenue loops as a `TenantModule` declaration in
> `features/custom/tenants/<slug>/` (the same path knowledge loops use via
> `register-knowledge-loop-templates.ts`), then mounts `LoopLifecycleFlow` on the
> surface that owns the loop. **Mounting note:** the flow is meaningful for loops
> that have all five phases (revenue, research). It is deliberately **not** forced
> onto the skill detail page, where a skill legitimately has no trigger and no
> stop-budget — rendering it there would show misleading "not wired" phases.
## Loss Function Development (the LFD overlay)
A finite spec ("make these tests green") is enough for a short loop. A
**long-running** optimization loop — one that iterates against a target over
dozens of rounds — needs a stronger thing: a **loss function** the agent
optimizes _over many iterations_. Without one, an agent that runs long enough
finds the cheap path: it memorizes the eval data, learns its misses into exact
keywords, and overfits whatever signal is visible. The fix is to make the loss
function explicit and **gameable-resistant**.
`OptimizationLoopSpec` carries an **optional** `lossFunction` block with the four
components of a strong loss function (generic optimization vocabulary, not
revenue-specific). Omit it for short, bounded loops; declare it when cheap-path
exploitation is a real risk.
| Component | Field | What it pins down |
| ----------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------- |
| **Target** | `lossFunction.evalTarget` | A large, **blind / held-out** eval set — referenced, not inlined (inlining lets the generator read it). |
| **Constraints** | `lossFunction.constraints` | Hard limits: token / $ / wall-clock budgets, allowed models / tools, methodology fences. |
| **Instruments** | `lossFunction.instruments[]` | Concrete measurement tools (a CLI, a check) that make the constraints + metrics **observable**. |
| **Forced entropy**| `lossFunction.entropy` | Anti-overfit policy: reflect each round, **jump strategy on a plateau**, log a hypothesis, forbid known exploits.|
The **inner loop** is the agent writing / running / fixing against the visible
`evidence.cases` with short feedback; the **outer loop** is `/goal` driving
sparse metric improvement against the blind `evalTarget` across iterations. The
human defines the loss function; the agent optimizes within it.
### Anti-cheat validation
`validateOptimizationLoop` adds `lf-*` **warnings** (advisory — they never block
compilation) that fire only when a loss function is declared, so pre-LFD specs
keep their exact prior validation surface:
- `lf-eval-not-blind` — a non-blind eval set lets the generator optimize the exact cases it is scored on.
- `lf-eval-set-too-small` — a held-out set below `MIN_BLIND_EVAL_SET_SIZE` (20) is memorizable.
- `lf-blind-eval-no-scorer` — a blind set with no `scorerRef` and no agent/server dimension cannot be scored.
- `lf-constraint-without-instrument` — a budget / wall-clock limit with no instrument measuring it is unenforceable.
- `lf-instrument-missing-ref` — a `cli` / `check` instrument with no command can't run.
- `lf-plateau-without-entropy` — plateau detection is on (`stop.noImprovementRounds`) but no forced-entropy response is declared.
### Non-inert by construction
The loss function is **not just metadata**. `compileOptimizationLoop` threads it
into the compiled `GoalSystemSpec` so it reaches the running agent, mirroring how
the stop budget is surfaced:
- `evalTarget` / `constraints` / `instruments` / anti-patterns → `spec.inputs` directives (rendered in the agent brief).
- budgets → `spec.stopConditions` (a budget is a stop condition).
- `entropy` → `spec.learningPath` trigger→update rules (reflect each round, strategy-jump on plateau, hypothesis log).
`formatOptimizationLoopBrief` renders a `## 8. Loss function (LFD)` section, and
`projectOptimizationLoopLifecycle` attaches a `lossFunction` health overlay
(component presence + the `lf-*` risk warnings) that `LoopLifecycleFlow` renders
as a strip below the five phases. Both are skipped entirely for loops with no
declared loss function.
| Symbol | Location | Role |
| ------------------------------------- | ------------------------------------------ | ---------------------------------------------------------------- |
| `OptimizationLossFunctionSchema` | `features/loops/lib/optimization-loop.ts` | The four-component loss function (optional on the spec) |
| `MIN_BLIND_EVAL_SET_SIZE` | `features/loops/lib/optimization-loop.ts` | Threshold below which a blind eval set is flagged memorizable |
| `LoopLifecycleLossFunction` | `features/loops/lib/loop-lifecycle.ts` | The lifecycle health overlay (presence + risk warnings) |
### Worked LFD example
`lossFunctionDistillationExample`
(`features/loops/lib/optimization-loop.examples.ts`) is a knowledge-graph
distillation loop that exercises all four components: a **blind held-out eval
set** of 240 cases (referenced, not inlined), hard **token / $ / wall-clock
budgets** plus a "do not read or edit the held-out set" methodology fence, a
**CLI scorer** and a **budget check** as instruments, and **forced entropy**
(reflect each round, strategy-jump on a 3-round plateau, hypothesis log, plus the
article's named cheap-path exploits as forbidden anti-patterns). It validates
with zero `lf-*` warnings — a template for an LFD loop done right.
## Design Decisions
### One engine, three entry points — no parallel executor (ADR-0060)
`runLoopTick` is the sole iteration body. Entry A (Session loop) is a new **caller** in `action-tick`, not a new engine. Entry B (Goal loop) already delegated to `runLoopTick` at `goal-loop-runner:423` before this epic. Entry C (Skill optimizer) has always been goal-less. Adding a new loop kind means adding a new caller of `runLoopTick`, nothing else.
### A goal entity is not required to run a loop (ADR-0060)
Single-output optimization is an execution policy — `action.metadata.loop: LoopSpec`. The Session loop entry point makes this explicit: no goal entity, no OKR overhead, no persistent cadence, no spend-lifecycle hooks. A goal entity is reserved for standing strategic objectives with human-tracked OKR semantics.
Alternative rejected: making Session loops a stateless variant of `GoalSystemSpec`. This would force every no-goal loop to carry `workSources`, `humanGates`, `impactMetrics`, and `learningPath` — goal vocabulary with no meaning on the no-goal path.
### `LoopSpec` projection is an allow-list (ADR-0060)
The inline projection at `goal-loop-runner:423` was replaced by `goalSystemSpecToLoopSpec` — a pure, tested function whose test asserts that every `LoopSpec` field is sourced from a **named** `GoalSystemSpec` field. A new `GoalSystemSpec` field added later cannot silently leak into the engine. This guards "three spec shapes drift" without collapsing the three shapes into one.
### Session uniqueness via `spawned_by_queue` (ADR-0060)
The existing `uniq_sessions_active_per_queue_entity` partial index is keyed on `(action_id, entity_id) WHERE status IN (...) AND (metadata->>'spawned_by_queue') IS NOT NULL`. The Session loop branch stamps `metadata.spawned_by_queue` so loop sessions fall under this index at zero migration cost — no new index, no `source = 'loop'` predicate on a clause the index does not have.
### Two named actions instead of a boolean toggle (ADR-0060)
A single boolean could silently switch the materialization backend between a transient `actions` row (no spend tracking, paused via `actions.status`) and a persistent goal entity (cadence, spend, lifecycle hooks). Two named actions with confirmation sheets make the distinction explicit, visible, and reversible.
### `maxDepth = 2` at config-validation time (ADR-0060)
Consensus verifier recursion is bounded at config-parse time, not at runtime. An async depth>2 would create nested `runLoopTick`-style fan-outs inside an Inngest step — unbounded in wall time. Failing at parse time is safer and surfaces the mistake to operators immediately.
### Dormant `TaskMetadata.consensus` / `refinement` deleted in the same phase (ADR-0060, surface-area-stewardship)
Both fields had zero non-test readers — they were receipts of a deleted extraction engine. Their semantics map directly onto `LoopSpec.consensus` and `VerifierRef{kind:"agent"}`. Keeping both alongside `LoopSpec` would mean two authored representations of the same semantics. Deleted in Phase D, the same phase that landed `action.metadata.loop`.
### One Loop primitive — completion_mode as the sole branch (ADR-0046)
Goals and persistent loops are the same primitive: a `goal` entity + a wired `actions` row. The ONLY difference is whether reaching the acceptance criterion stops the loop. A single `completion_mode` field captures that; the runner branches on it and nothing else differs. This honors the six-primitive architecture (ADR-0002/0007) and avoids a parallel system.
Rejected alternatives: a `loops` table (parallel system); a separate Goal primitive (same); a bespoke value engine (the grade×estimate mapping Tyler explicitly rejected — "overly-opinionated hardcoded logic").
### One execution spine — action-tick absorbs goalLoopCron (ADR-0016 amended)
The deleted `goalLoopCron` (`*/15`) was a second cron over a second table — the exact pattern ADR-0016 retired by collapsing `view_scan` dispatch into `action-tick`. Step 5 applies the same fix. `action-cron` (`*/1`) is the SOLE scheduler; goal check-ins ride Branch 2 via the `goal-checkin` ActionDefinition. The ActionDefinition is a skill orchestrator (deterministic), not an LLM turn — it calls `runGoalLoopCheckIn` which contains the skills/grading logic, so Branch 3 (full model run) is not involved.
### Realized value via promotion — never grade×estimate
The value model rejects any fallback that converts a quality score (0–100) to dollars without a cents-field anchor. A loop that produces only quality grades reports `realizedValueCents: null` — quality-only ROI, not fabricated dollars. This avoids the "hardcoded grade→cents mapping" failure mode and keeps the platform fork-portable (the ROI/EV lens is product config, not platform code).
### Cost denominator: ai_budget_allocations first
`cost_events.driver_id = rootActionId` undercounts cost in loops with delegated agents, multi-action pipelines, or chat spend on the goal. `ai_budget_allocations.actual_cents` captures the full envelope. The fallback to `cost_events` exists for loops that have no allocation row (older or quality-only loops).
### Prove-value auto-pause as a pure policy
The auto-pause decision is a pure function (`evaluateProveValue`) called inside the `goal-checkin` ActionDefinition on every fire — no separate scanner, no new Inngest function. This makes the policy independently testable and keeps the execution-spine collapse clean. Quality-only loops are exempt because there is no dollar signal to evaluate.
### Closure score and achievement score are separate concerns
Wired correctly ≠ winning; winning through other means ≠ wired correctly. The closure score is a fixed 6-criteria structural audit. The achievement signal lives in `entity_responses` and the goal entity's `status` field. These are intentionally orthogonal.
### Goal as entity, not a dedicated table (ADR-0032)
Goals are `entity_type='goal'` records in the existing `entities` table, linked via `actions.goal_entity_id` FK. This gives loops the full entity surface (comments, scoring, realtime, @-mentions, schema-driven forms) without a parallel system.
### Atomic wizard materialization
The wizard calls a SECURITY DEFINER RPC (`materialize_loop_from_wizard`) that creates the goal entity, actions, FK link, and trigger config arm in a single transaction. No orphan goal entities from partial wizard failures. The RPC follows the `submit_relation_scored` security pattern: `SET search_path = ''`, fail-closed on NULL `auth.role()`, tenant-match re-check, `ROW_COUNT` guard.
### Output Log at Runs tab, not a standalone page
The old `/goals/[id]/outputs` route 308-redirects into the unified `/goals/[id]?tab=runs`. Outputs are entities. The consolidation removes a parallel page without losing any data — the Runs tab shows check-in timeline, projected work queue, and output log in one place.
### LFD as an optional overlay, not a second loop type
Loss Function Development is rigor _added to_ the existing optimization loop, not a parallel "LFD loop." The `lossFunction` block is optional on `OptimizationLoopSpec`; its four components map onto the same spec → `compileOptimizationLoop` → `GoalLoopTemplate` → goal-loop-runner path as everything else (eval target and constraints become `inputs` + `stopConditions`; entropy becomes `learningPath` rules). There is no new executor, scorer, store, or enforcement engine — the anti-cheat checks are `warn`-only and the policy is agent-advisory via the brief, exactly like the existing stop budget. This keeps the article's discipline a thin, fork-portable lens rather than a hardcoded behavior.
## Related Modules
- [Knowledge Loops](/docs/features/knowledge-loops) — `features/loops/lib/knowledge-loop.ts`; a typed, tenant-declarable lens (sources → synthesis → records → review → surfaces → eval) that compiles to a `GoalLoopTemplate` and rides this machinery (ADR-0057)
- `features/loops/lib/loop-core.ts` — `runLoopTick`, `LoopSpec`, `VerifierRef`, `loopSpecSchema`
- `features/loops/lib/loop-spec-projection.ts` — `goalSystemSpecToLoopSpec` allow-list projection
- `features/loops/lib/consensus.ts` — `pickMajority`, `pickUnanimous`, `pickBestConfidence` vote strategies
- `features/loops/lib/loop-portfolio-view.ts` — unified `LoopKind` portfolio view (goal + session)
- `features/loops/server/loop-session.ts` — Session loop branch (`runLoopBranch`, `spawned_by_queue` stamp)
- `features/loops/server/author-loop.ts` — `buildLoopSpecFromCriteriaSet`, `createOutputLoop`, `createStandingGoalLoop`
- `features/loops/server/promote-entity-loop-candidate.ts` — candidate promotion with row-count guard
- `features/evals/lib/verifier.ts` — `resolveVerifier`, four verifier kinds, `VerifierContractError`
- `features/actions/` — root action is the loop identity node; `actions.goal_entity_id` FK; `action-tick` Branch 2 executes `goal-checkin`; Branch 3 executes Session loops
- `features/actions/library/builtins/goal-checkin.ts` — the ActionDefinition replacing `goalLoopCron`
- `features/entities/lib/goal-state.ts` — state reconciliation: three axes, valid transitions, `completion_mode` default
- `features/criteria-sets/` — critic matching; the acceptance scorecard; terminal halt gate; `scored`/`threshold` per-dimension routing
- `features/responses/` — `entity_responses`, scoring, promotion writes realized value; `CriteriaSetDimension.scored`
- `features/loops/server/aggregate-loop-economy.ts` — single economy/ROI computation
- `features/loops/lib/prove-value-policy.ts` — pure prove-value auto-pause policy
- `content/docs/features/sessions.mdx` — session execution that produces output entities and `cost_events`
- `content/docs/features/actions.mdx` — action registry, triggers, `action-tick` spine
- `content/docs/architecture.mdx` — field dependency DAG context
- `documents/adr/0060-loop-execution-engine.md` — ADR-0060: canonical extension contract for the unified engine (one `runLoopTick`, three entry points, pluggable verifier)
- `documents/adr/0046-loop-os-unification.md` — full Loop OS ADR with build sequencing and trade-offs
- `documents/adr/0032-loop-goals-as-entities.md` — ADR for the goal-as-entity decision
- `documents/adr/0016-action-tick-spine.md` — ADR for the unified `action-tick` cron spine (amended by ADR-0046 and ADR-0060)