← All posts

Earned autonomy: an alignment ledger and a trust ladder

Every decision a person makes in the review queue is written to a ledger. A six-rung ladder per action kind reads that ledger, and a promotion is refused unless the numbers are there. The thresholds, the schema, and how it maps onto trust.yaml.

Vocion Teamvocion-v2.74.0

Status: shipped. vocion-core #338 is running, migration 0099_decision_alignment.sql included. Merged onto the release branch release/2026-09-15-rail (7db4f7f6) and deployed to a deployment we operate; migrations 0094–0099 are applied. The pull request is still landing on main individually behind that release branch.

"Can an agent earn more autonomy by being right?" is a product question with a schema answer. You need somewhere to record whether it was right, a definition of "more autonomy" that is not a slider, and a rule that stops a person promoting on vibes.

The ledger

CREATE TABLE IF NOT EXISTS "decision_alignment" (
  "id"            serial PRIMARY KEY NOT NULL,
  "org_id"        text NOT NULL,
  "subject_kind"  text NOT NULL,   -- 'action' | 'ask'
  "subject_key"   text NOT NULL,   -- the action id, or the ask kind
  "subject_id"    integer NOT NULL,
  "agent_slug"    text,
  "decision"      text NOT NULL,   -- approved | edited | rejected | <option id>
  "recommended"   text,            -- approve | reject | snooze | <option id>; null = no view
  "implicit"      boolean DEFAULT false NOT NULL,
  "agreed"        boolean,         -- null when nothing was recommended
  "confidence"    real,
  "auto_executed" boolean DEFAULT false NOT NULL,
  "has_note"      boolean DEFAULT false NOT NULL,
  "decided_by"    text,
  "decided_at"    timestamp DEFAULT now() NOT NULL
);
CREATE UNIQUE INDEX "decision_alignment_subject_decision_idx"
  ON "decision_alignment" ("org_id","subject_kind","subject_id","decision");

Three columns carry the argument.

implicit marks a proposal the agent made without stating a recommended decision. An agent only proposes work it wants run, and the autonomy question is "had this executed without you, would you have let it" — which an implicit approve answers. The adoption metric on the review page deliberately excludes these; this ledger deliberately keeps them, labelled, because they are exactly the evidence a ladder needs.

auto_executed marks a decision taken on something that already ran under a trust rule. A rejection there is the strongest demotion signal available.

The unique index makes the table append-only but idempotent per subject and decision, so re-deciding a run cannot double-count it.

Agreement is computed at write time, not read time:

const compared = input.outcome ?? input.decision;
const agreed = input.recommended === null ? null : compared === input.recommended;

outcome exists because triage verbs are not one-to-one with recommendations: approve and edit-then-approve both map to approve.

The score

export type AlignmentScore = {
  /** agreed / n, or null when nobody has decided a recommendation of this kind yet.
      Null, never 0 — 0 would read as "always wrong". */
  agreementRate: number | null;
  n: number;         // decisions where something was recommended
  agreed: number;
  decided: number;   // every decision, recommended or not
  rejected: number;
  withNote: number;  // came with a note — a correction the classifier can read
  window: AlignmentWindow;
};

AlignmentService.scoreFor is one aggregate query over a window of '7d' | '30d' | 'all', defaulting to thirty days. There is no recency weighting, no decay, and no confidence weighting. That is a deliberate choice: a number a person can recompute by hand is a number they will trust when it takes something away.

The ladder

Six rungs, declared in TypeScript rather than as a database enum:

export const RUNGS = [
  'observe',
  'recommend',
  'assist',
  'execute-with-approval',
  'execute-within-bounds',
  'autonomous',
] as const;

export const DEFAULT_RUNG: Rung = 'execute-with-approval';
export const FIRST_AUTOMATED_RUNG: Rung = 'execute-within-bounds';

The rung belongs to an (org, action kind) pair. Not to an agent, not to a mission. "Can this deployment send email without asking" is a different question from "is this agent good", and conflating them is how a good agent gets a dangerous permission.

There is an honest limit here, and the guide states it plainly: operationally a rung means one thing only — whether the trust_rule the action gate reads is enabled. The other four rungs are stated intent. They show on the page and in the team report, but the gate has two states.

export function trustRuleFor(rung: Rung, minConfidence: number) {
  return { enabled: rungAutomates(rung), threshold: minConfidence };
}

Demoting keeps the threshold and only flips enabled, so re-promoting later needs no retyping.

The thresholds

export const TIER_RULES: Record<RiskTier, TierRule> = {
  low:    { minN: 20,       minAgreement: 0.9,  rejectionWindowDays: 14, rejectionsCounted: 'any',             minConfidence: 0.85, ceiling: 'autonomous' },
  medium: { minN: 40,       minAgreement: 0.95, rejectionWindowDays: 30, rejectionsCounted: 'high-confidence', minConfidence: 0.95, ceiling: 'execute-within-bounds' },
  high:   { minN: Infinity, minAgreement: 1,    rejectionWindowDays: 30, rejectionsCounted: 'any',             minConfidence: 0.99, ceiling: 'execute-with-approval' },
};
TierDecisions (30d)AgreementRejectionsConfidence floorEvidence can reach
lown ≥ 20≥ 90%none in 14 days0.85Operate autonomously
mediumn ≥ 40≥ 95%no high-confidence rejection in 30 days0.95Execute within bounds
high———0.99never above Execute with approval without an explicit rule

minN: Infinity for high risk is not a large number. It is unreachable. High-risk kinds are not promoted by evidence at all; they are raised by a person editing the workspace's trust.yaml, which is a reviewable change in a repository rather than a click.

An unlisted external action defaults to high. The ladder assumes the worst about an action it has not been told about.

The autonomy page: one row per action kind, with the rung, risk tier, alignment and what the next rung takes. The "Next rung" column is the output of a pure function. When the numbers are not there it says which number is missing, because that is the one a person can do something about today.

The refusal is the product

export async function promote(orgId: string, actionId: string, by: string) {
  const e = await eligibility(orgId, actionId);
  if (!e.nextRung) { throw new AutonomyError('AT_TOP', `${actionId} is already at the top of the ladder`); }
  if (!e.earned)   { throw new AutonomyError('NOT_EARNED', `Not earned yet: ${e.reason}`); }
  await writeRung({ /* … */ evidence: { ...e.evidence, promotedFrom: e.effective.rung, at: new Date().toISOString() } });
}

The button is absent from the page unless this call would succeed, and an API caller who tries anyway gets told exactly what is missing. The promotion freezes the evidence into the row, so the audit trail is the numbers it was earned on rather than who clicked.

Demotion is asymmetric and automatic on two triggers: a rejection of something that already auto-executed, and a rejection of a high-risk proposal. Its floor is Execute with approval — the system can take automation away, never the ability to propose.

Above all of it sits a list that is not configurable:

export const NEVER_AUTO_ACTION_IDS: ReadonlySet<string> = new Set([
  'gmail.send', 'discovery.review_proposal', 'personalization.enroll', 'objects.propose_candidate',
]);

Read by both the gate and the ladder, so the page can never promise an automation the gate would refuse.

Mapping onto trust.yaml

Both new fields are optional, so existing manifests keep working:

rules:
  - action: gmail.send
    autoApproveAbove: 0.99
    enabled: false
    # rung: execute-with-approval   # optional; must agree with `enabled`
    # risk: medium                  # optional tier override
risk:
  hubspot.update: low               # tier for a kind with no rule yet

Omitted, an enabled rule reads as execute-within-bounds and a disabled one as execute-with-approval. Authoring a rung that automates on a disabled rule fails the apply, with the reason stated: the rung and the rule would disagree about what runs, and the page and the gate must never disagree.

What is not true yet

No workspace has authored rung: or risk: yet, so that applier path is covered by unit tests only. Exporting an in-app promotion back into trust.yaml is documented and not built. Workflow and mission decisions are not in the ledger — the subject kinds are action and ask.

Where this lives in the repo

Read off release/2026-09-15-rail at 7db4f7f6, the commit that shipped. #338 is merging to main individually behind it.

Related: What an AI agent autonomy level gates and Agents now recommend a decision.