Files

154 KiB
Raw Permalink Blame History

status, created, updated, revision, owner, decisions
status created updated revision owner decisions
draft 2026-08-12 2026-08-13 5 nova28
../../decisions/0027-replayable-schema-migrations.md
../../decisions/2026-08-02-class-aware-git-subprocess-admission.md

Task delivery ledger

Why

A task that never opened a pull request is currently indistinguishable from a task that produced nothing. In the reference store that is the majority of the board: of 75 observed (task, repository) pairs, only 16 carry a merged pull or merge request. The remaining 59 are one undifferentiated bucket mixing research cards, chores, work delivered by committing straight to the default branch, and genuine failure.

Kandev already holds enough to split most of that bucket. It does not, and cannot today, answer the one question that would close it: did this card's work ever reach the repository's default branch? No merge-into-default detection exists anywhere. patRepository.DefaultBranch (apps/backend/internal/github/pat_client.go:1017) is parsed for clone metadata and, at :1160, copied onto pr.BaseDefaultBranch; neither use infers a merge. The push webhook — processPush in apps/backend/internal/github/webhook_service.go — publishes branch and SHA but is consumed only by the automation engine (apps/backend/internal/automation/github_webhook_subscriber.go, its GitHubPushReceived subscription), never for merge inference.

Separately, Kandev's own success metric is wrong in a way that lands in the same analysis. FinishRun writes status='finished' on eight terminal call sites, six of which did no work, and agent_summary.go counts every finished row as a success. Only two of the eight — the agent-completed subscribers — follow an agent actually running. The remaining scheduler guards record the reason that work did not run, as spelled out under Office run outcome.

This spec defines a delivery ledger that records how, and whether, each (task, repository) pair delivered; and a run outcome that stops Office from reporting non-work as success.

Citation convention

Every path:line in this document is a convenience pointer, not the contract. Where a line number is paired with a symbol name, a function name, or a semantic label, the label is authoritative and the line number is not. If the two disagree at build time, follow the label and correct the line. This matters most for the FinishRun call-site table under Office run outcome, where each site carries the outcome it must record: a drifted line number must never be allowed to mis-map an outcome.

Evidence the classification already works

Every number below was measured against the reference store (~/.kandev/data/kandev.db, snapshotted 2026-08-12) before any schema was proposed, as the card required. They are the input inventory, not estimates.

Observation Value
task_session_git_snapshots rows 1,655
... with ahead > 0 1,390
... with empty head_commit 0
Sessions with ahead = 0 and one distinct head_commit (produced nothing) 31
Sessions with ahead > 0 and a moving head_commit (authored commits) 48
Sessions with ahead > 0 and a static head_commit (inherited a branch) 8
Sessions with ahead = 0 and a moving head_commit 0
Sessions working directly on their repository's default branch 1 (3 distinct heads, ahead 2)

Applying the precedence in Classification to that store, with no Kandev change, classifies 38 of 75 (task, repository) pairs:

Outcome Pairs
pr_merge 16
direct_commit 1
no_delivery_observed 21
unknown 37

Three findings from that pass change the design and are therefore normative context, not commentary:

  1. ahead is base-branch-relative, not upstream-relative (apps/backend/internal/agent/runtime/lifecycle/event_types.go, the Ahead field). It counts commits the branch has that its base does not, and it does not fall to zero when the branch is pushed. It is therefore a valid "this branch carries work" signal and an invalid "this session authored work" signal — the 8 sessions above with ahead > 0 and a static head inherited someone else's branch. This is why the ancestry precondition in Default-branch observation keys off a moving head_commit and not off ahead. Note the grain: every row in the table above counts sessions, not pairs. Head movement is only evidence of authorship within one session, so every predicate derived from this finding is evaluated per session and never by pooling a pair's sessions together — see Classification, "Head movement is a per-session fact".
  2. Merge cannot be detected by commit ancestry in this repository. Kandev squash-merges by policy, so a merged branch head never becomes an ancestor of the default branch. Receipt: PR #2514's branch head 4dfa4d545 "fix(web): key mobile theme toggle off resolvedTheme" is present in the object store and is not an ancestor of origin/main; the commit that actually landed is the squash 15524de62 "fix(web): show dark mode toggle in mobile menu (#2514)". Ancestry is a valid positive signal and an invalid negative one.
  3. Pull requests are not a GitHub-only concept here. Three provider tables exist — github_task_prs, gitlab_task_mrs, azure_devops_task_prs — and the reference store's GitLab table holds 4 merged merge requests on a repository with no GitHub provider at all. A GitHub-only reading of pr_merge undercounts by those 4. The three tables do not share a schema, which the Provider predicates table below resolves column by column: azure_devops_task_prs has no merged_at and no detached_at; gitlab_task_mrs has merged_at but no detached_at.

A fourth finding corrects the card's premise about the Office metric: only three of the FinishRun paths in scheduler_integration.go write an office_activity_log row — run_idle_skipped, run_budget_blocked and run_processed. The agent-inactive, task-tree-hold, checkout-error and checkout-unavailable paths log nothing, and the two event_subscribers.go completion paths log a run event rather than an activity row. The activity log therefore cannot carry this on its own.

What

  • Kandev maintains a delivery ledger: at most one row per (task, repository), recording a delivery outcome, the evidence that produced it, and — separately — whether the pair's work was ever observed reaching the repository's default branch.
  • The ledger is a new table. It is not a pull-request state and does not extend github_task_prs, gitlab_task_mrs, or azure_devops_task_prs.
  • delivery_outcome takes exactly one of pr_merge | direct_commit | no_delivery_observed | unknown, or NULL when the pair has never been evaluated.
  • Reaching the default branch is recorded as its own observation, because the route by which work reached the default branch is frequently unknowable while the fact that it arrived is not.
  • An Office run records a structured outcome alongside its terminal status, so a budget-blocked, idle-skipped, agent-inactive, tree-held, checkout-blocked or never-launched run is no longer counted as a success.
  • Every column added by this feature is nullable, legacy rows are NULL and never 0 or '', and the instant each mechanism became authoritative is published in the database so a point-in-time extract can tell "not observed" from "observed as nothing".

Delivery slices

This spec defines two contracts that share no build-order dependency. They are specified together because they are the same defect at two grains, and they are built in this order, each closing on its own:

  • Slice A — Office run outcome. One nullable column, eight call sites, one new field on models.Run, one reshaped repository query and the two dashboard response shapes it feeds. Depends on nothing in Slice B.

    models.Run is not optional. Ten production queries read SELECT * FROM runs and StructScan into office/models.Run (eight in internal/runs/repository/sqlite/runs.go, plus office/repository/sqlite/failure.go and run_routing.go), and no sqlx handle in this backend is Unsafe(). A column that exists in the table with no matching db: tag on the struct makes every one of those queries fail at runtime, which would take out the whole Office run pipeline. models.Run therefore gains Outcome *string with json:"outcome,omitempty" and db:"outcome", pointer-typed for the same reason the provider-routing columns on that struct are pointer-typed — so StructScan reads the NULL that every pre-activation row carries.

    Adding that field does not, by itself, change any response body. models.Run is never serialized to a client: every run surface flattens it through an explicit DTO with its own field list. No run-grain DTO gains a field in this card — specifically RunListItem (via runToListItem), AgentRunSummaryDTO (via buildRunSummaryDTO) and RunRuntimeDTO (via buildRuntimeDTO), all in office/dashboard/, are all unchanged. So no per-run response body changes and the "Any user-visible surface" exclusion in ## Out of scope stands as written.

    Two day-grain dashboard shapes DO gain fields, and that is in scope. AgentRunActivityDay gains skipped and unclassified, and AgentSuccessRateDay gains unclassified; the contract is specified under ## Office run outcome § Response shapes and summarized in ## API surface. All three are additive JSON fields that no frontend renders yet. The distinction is run-grain versus day-grain, not "nothing changes" — an earlier revision of this document said flatly "no DTO gains a field", which contradicted the Response shapes section and could have been read as licence to ship the reshaped query with no wire fields at all.

    Additive on the wire is not the same as unchanged on screen. Both shapes already feed rendered charts on the agent dashboard, and this card changes the values they carry: the Success Rate chart in particular reports 0% for every pre-activation day once succeeded narrows to outcome = 'processed'. No apps/web/ file changes and no new copy is introduced, so the E2E path allowlist is unaffected — but nobody should read "no DTO field is removed" as "no operator sees a difference". The new unclassified field is what makes that difference explainable in-band; see § Response shapes.

  • Slice B — the delivery ledger. New table, evaluator, sweep, upsert guard, metrics, ancestry, two-dialect migrations.

Sequencing is a plan-level decision recorded here so it survives context resets; it changes no acceptance criterion. Both slices are in scope for this card.

Data model

task_delivery_ledger (new)

One row per (task_id, repository_id). Every column that carries evidence — the classification triple, the default-branch observation triple, observed_branch_commits and first_classified_at — is nullable, so "not observed" is always representable and is never spelled as 0 or ''. The columns that are NOT NULL are exactly: the identity columns (id, task_id, repository_id), the denormalized scope column workspace_id, the bookkeeping columns (last_evaluated_at, evaluation_seq, created_at, updated_at), and evidence_rank, whose non-nullability is a correctness requirement argued in its own row below. The table is authoritative; this sentence enumerates rather than generalizes, because an earlier revision said "all columns other than the identity, sequence and timestamp columns are nullable" and its own table contradicted it two rows later.

Column Type Meaning
id TEXT PK Row identity.
task_id TEXT NOT NULL FK to tasks(id) ON DELETE CASCADE.
repository_id TEXT NOT NULL FK to repositories(id) ON DELETE CASCADE. Never empty. The ON DELETE clause is stated rather than left to the implementation, for the same reason it is stated on task_id: repositories itself declares FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE and DELETE FROM workspaces is a live production path, so repository rows are removed in ordinary operation even though nothing hard-deletes them directly. A bare FK (dialect default NO ACTION) would make workspace deletion begin failing the moment any ledger row referenced one of that workspace's repositories, which is a regression this feature would introduce into an existing user-facing operation.
workspace_id TEXT NOT NULL Denormalized from the task, for extract scoping. Refreshed on every persisted evaluation — it is not write-once and not rank-guarded, because it is an identity attribute of the task rather than evidence about delivery. A task moved between workspaces is picked up because tasks.updated_at is a due source (see Sweep selection predicate).
delivery_outcome TEXT NULL pr_merge | direct_commit | no_delivery_observed | unknown. NULL means never evaluated.
delivery_basis TEXT NULL The evidence class that produced the outcome. See Basis vocabulary.
delivery_ref TEXT NULL The identifying evidence: a provider pull/merge request URL, or a commit SHA.
evidence_rank INTEGER NOT NULL DEFAULT 0 The rank of the (outcome, basis) pair that produced this row. See Evidence rank. Persisted so the upsert guard needs no lookup table at write time. NOT NULL by design — the upsert guard compares this column in SQL, and x >= NULL is NULL (falsy) on both dialects, so a nullable rank would silently suppress the first real write. This table is new, so it has no legacy rows and the "legacy rows get NULL" rule does not apply to it; that rule governs columns added to existing tables, i.e. runs.outcome.
reached_default_at TIMESTAMP NULL When work for this pair was first observed reaching the repository's default branch. Write-once.
reached_default_basis TEXT NULL How that observation was made. See Basis vocabulary.
reached_default_ref TEXT NULL The commit SHA or pull/merge request URL that carried the observation.
observed_branch_commits INTEGER NULL Monotonic high-water mark of ahead. See Ordering, idempotency, concurrency.
first_classified_at TIMESTAMP NULL When a non-NULL delivery_outcome was first written. Write-once. Its instant is the one the evaluation began reading its inputs at, the same source as last_evaluated_at, and it is fixed here for the same comparability reason this document gives for reached_default_at: a timestamp column whose source varies between implementations cannot be compared across rows.
last_evaluated_at TIMESTAMP NOT NULL Advances on every persisted evaluation, including no-change evaluations. Its value is the instant the evaluation began reading its inputs, never the instant the upsert committed — see Sweep selection predicate, "Which instant last_evaluated_at records".
evaluation_seq INTEGER NOT NULL Monotonic per row; incremented on every persisted evaluation.
created_at TIMESTAMP NOT NULL
updated_at TIMESTAMP NOT NULL Advances only when a classification or observation column changes.

UNIQUE(task_id, repository_id). Index on (workspace_id, last_evaluated_at) for the extract and the stall check.

reached_default_at is deliberately not derivable from delivery_outcome and does not participate in it. A pair may reach the default branch while its outcome stays unknown (see Classification, rule 3).

Basis vocabulary

delivery_basis:

Value Meaning
provider_pr_merged A non-detached provider pull/merge request for this pair reports merged.
default_branch_commit The head commit advanced on snapshots whose branch is the repository's default branch.
reached_default_unattributed Work reached the default branch, but by no route Kandev observed.
branch_commits_unmerged Commits exist on a non-default branch and no delivery evidence was found.
no_commits_observed Snapshots exist for this pair and every one shows no commits.
provider_pr_unmerged No snapshot exists, and no provider pull/merge request row for this pair reports merged-and-not-detached. This is deliberately weaker than "every row reports not-merged": a row that merged and was later detached satisfies this basis, because rule 1 excludes detached rows from counting as delivery. The wording matches rule 5's predicate exactly, so a consumer must not read this basis as "no merged request ever existed" — it asserts that no attributable merged request exists now.
no_observations Neither a snapshot nor a provider pull/merge request row exists.
default_branch_unknown The repository's default_branch is empty, so default-branch rules could not be evaluated. Supersedes the matched rule's own basis; see Degraded evaluation.

reached_default_basis:

Value Meaning
provider_pr_merged Derived from a merged, non-detached provider pull/merge request whose base branch equals the repository's default_branch. Non-detached matters and is not inherited by implication: rule 1 excludes detached rows, so without the word here a detached row could offer an observation whose ref-selection set is empty.
default_branch_commit The pair's session committed on the default branch itself.
ancestor_of_default The pair's selected head commit is an ancestor of the default branch in the local checkout.
push_webhook_default A verified push webhook reported this pair's head commit on the default branch ref. Defined for a later card; nothing writes it here (see Out of scope).

Evidence rank

The upsert guard and the monotonicity rule are both defined over a single integer derived from the (delivery_outcome, delivery_basis) pair. Rank is a property of the evidence, not of the outcome enum — that is what makes unknown with observed commits stronger than no_delivery_observed, which the outcome enum alone cannot express.

Rank delivery_outcome delivery_basis
0 NULL NULL (never evaluated)
1 any default_branch_unknown
2 unknown no_observations
3 unknown provider_pr_unmerged
4 no_delivery_observed no_commits_observed
5 unknown branch_commits_unmerged
6 unknown reached_default_unattributed
7 direct_commit default_branch_commit
8 pr_merge provider_pr_merged

The rank is injective over ranks 2–8, and deliberately not over ranks 0 and 1. State this precisely, because an earlier revision of this document claimed injectivity over the whole ladder and that claim was false:

Rank Injective? Why
0 n/a The never-evaluated sentinel. It is not a classification and no evaluation ever computes it.
1 No, by design Every degraded evaluation lands here regardless of which rule matched, so rank 1 admits (pr_merge, default_branch_unknown), (unknown, default_branch_unknown) and (no_delivery_observed, default_branch_unknown). See Degraded evaluation.
2–8 Yes Each maps to exactly one (outcome, basis) pair and each reachable non-degraded pair maps to exactly one rank.

Rank 1 is non-injective on purpose and the alternative was considered and rejected: giving each degraded (outcome, default_branch_unknown) pair its own rank would destroy the property that makes degraded rows safe — that a degraded row is the weakest evidence and is therefore superseded by any real evaluation once default_branch is populated. A degraded pr_merge at some higher rank would block a later, sighted unknown / no_observations at rank 2, which is exactly backwards: the degraded row was computed blind to the default branch and must never outrank a row that was not.

What injectivity over 2–8 buys is narrow and is the only thing that rests on it: at ranks 2–8, equal rank implies the same (outcome, basis), which is what makes the equal-rank rule under Monotonic evidence a no-op in outcome and basis. It does not imply the delivery_ref is unchanged — see that section. Rank 1 has its own equal-rank rule, stated under Degraded evaluation.

Do not add a value to ranks 2–8 without giving it its own rank.

The rank order is the order in which evidence accumulates in practice: a pair begins unobserved, may acquire an unmerged pull request, then snapshots showing nothing, then commits, then a default-branch observation, then a route. No transition in that sequence is a demotion, which is the property Monotonic evidence relies on.

runs.outcome (new column)

Nullable TEXT on the existing runs table: processed | budget_blocked | budget_unmeasurable | idle_skipped | agent_inactive | task_tree_held | workspace_paused.

NULL for every row written before activation, for any run that never reaches a terminal status, and for every run that reaches status = 'failed' (see Office run outcome, the FailRun bullet). On the finished path the writer writes one of the seven values above; it never writes ''. No database constraint is added — the reader is total over every possible value (see Office run outcome), so an unrecognised value degrades to skipped rather than breaking a query. runs.status semantics are unchanged: a blocked run still reaches finished, so every existing consumer of status keeps working.

Activation points

Each mechanism writes its activation instant into the existing kandev_meta key/value table, alongside the schema_initialized_at key already written by persistence.Provide:

  • telemetry.delivery_ledger.activated_at
  • telemetry.run_outcome.activated_at

Both are RFC3339 UTC and never overwritten on replay.

An activation instant is written only after its schema is verified present. This is the single most important rule in this section, because the migration runner (MigrateLogger.Apply) logs a failure at WARN and swallows it, so a migration that did not apply is invisible at boot. Writing the key "on first application" would therefore publish an activation instant for a table or column that does not exist — and a consumer reading that key would conclude the mechanism was live and treat every absent row as "observed as nothing", which is the exact inversion the activation point exists to prevent.

So each key is written only after a positive check, at the same boot, that its schema actually exists:

  • telemetry.delivery_ledger.activated_at — only after a probe confirms task_delivery_ledger is present and queryable.
  • telemetry.run_outcome.activated_at — only after a probe confirms runs.outcome is present.

If the probe fails the key is not written, nothing is logged as success, and the next boot retries both the migration and the probe. An absent activation key therefore means "this mechanism is not live", which is a true statement a consumer can act on; a present one means the schema was verified. The two are never ambiguous.

The activation key does not gate the writer. The sweep starts and runs on its normal cadence whether or not either key was written, and a failed probe never disables it. This is stated because the opposite is the tempting reading and it would be self-defeating: if a failed probe stopped the sweep, then a swallowed migration would produce no upsert attempts, so delivery_ledger_write_errors_total would stay at 0 and delivery_ledger.write_failed would never be logged — the migration-failure signals under Failure modes would all be silenced by the very condition they exist to detect. The key is a published fact for consumers, describing when the data became trustworthy. It is not a feature flag.

Reset parity. internal/system/database/reset.go drops every user table but deliberately preserves kandev_meta. Left alone, that would leave an activation instant pointing at a moment before data that no longer exists — inverting the guarantee the activation point exists to provide, because a post-reset task with no ledger row would read as "observed as nothing" rather than "not observed". The reset path therefore deletes both telemetry.*.activated_at keys along with the user tables it drops, so the next boot re-establishes an activation point that matches the data actually present. This is the only change this spec makes to the reset path.

Ordering is fixed, because the reset drops tables one at a time and is not transactional. The keys are deleted first, before any user table is dropped. The two orders are not equivalent under partial failure and only one of them fails safe:

  • Keys first, then drops. If a drop then fails, the database holds ledger rows with no activation key. A consumer reads "this mechanism is not live" and treats the rows as untrusted — conservative, and recoverable on the next boot, which re-probes the schema and rewrites the key.
  • Drops first, then keys. If the key deletion then fails, the database holds an activation instant pointing at data that no longer exists. A post-reset task with no ledger row reads as "observed as nothing" rather than "not observed" — the precise inversion this section exists to prevent, and one nothing later detects, because a present key is exactly the state a healthy system is in.

The asymmetry is the whole argument: a missing key is a true statement that costs a boot to correct, while a stale key is a false statement that survives indefinitely. If the key deletion itself fails, the reset fails and is reported as such rather than proceeding to drop tables.

Classification

For each candidate (task, repository) pair, rules are evaluated in this exact order and the first match wins.

What a predicate may read. Every predicate below reads the inputs observed on this evaluation, plus the default-branch observation this evaluation has just computed. No rule reads a stored classification column (delivery_outcome, delivery_basis, delivery_ref, evidence_rank, observed_branch_commits). Rule 3 is the only rule that reads an observation, and it reads the freshly computed one, never the stored reached_default_at. Evaluation order within a single evaluation is therefore fixed: the default-branch observation is computed first, then the rules are matched.

The consequence is deliberate and must not be treated as a defect: if a pair's ancestry check errors or is skipped on a later evaluation, that evaluation computes branch_commits_unmerged (rank 5) rather than reached_default_unattributed (rank 6). That is a demotion, so Monotonic evidence suppresses it, the stored row keeps rank 6, and delivery_ledger_demotions_suppressed_total increments. Reading the stored column instead would hide a real evidence regression behind an equal-rank no-op; counting it is the point.

Normalization applied before any rule runs:

  • A head_commit that is NULL, empty, or whitespace-only is not a distinct head value and is ignored everywhere in this document, not merely in this section. It is never counted toward a distinct-head predicate, never selected as a ref, and never submitted to git. The scope is stated document-wide because two selection rules that consume head_commit — the ancestry head under Default-branch observation, "Which commit", and the direct_commit ref under Ordering — live in other sections and select by greatest created_at; scoped to this section alone, a newer empty head would outrank a real one and reach either git --is-ancestor or a write-once column. Both rules restate the filter locally so neither can be read in isolation and get it wrong.
  • An ahead that is NULL or negative is read as 0.
  • A repositories.default_branch that is NULL is read as the empty string, and an empty default_branch triggers Degraded evaluation. The column is TEXT DEFAULT '' and not NOT NULL, so both forms occur.
  • A task_sessions.repository_id that is NULL is read as the empty string, and an empty value attributes the session to no pair (see Failure modes). That column is also TEXT DEFAULT '' and not NOT NULL.
  • Branch comparison against repositories.default_branch is exact and case-sensitive, and never matches when default_branch is empty — an empty default_branch is not a branch name, and treating it as one would let a provider row or snapshot with an equally empty branch column compare equal. See Degraded evaluation.

Head movement is a per-session fact. "Two or more distinct non-empty head_commit values" is evaluated within a single session's snapshots, and a pair satisfies such a predicate when at least one of its sessions does. It is never computed by pooling every session's snapshots together. A task may have many sessions on one repository, and two sessions that each merely inherited a different branch would pool into two distinct heads and falsely read as authored work — the exact false positive the ancestry precondition exists to prevent. The Evidence table above was measured per session for the same reason.

  1. pr_merge — a provider pull/merge request row exists for this pair, is not detached, and reports merged (see Provider predicates). Basis provider_pr_merged.
  2. direct_commit — at least one of the pair's sessions has two or more distinct non-empty head_commit values among its own snapshots whose branch equals the repository's default_branch. Basis default_branch_commit.
  3. unknown — commits were observed: the greatest ahead across the pair's snapshots is > 0, or at least one of the pair's sessions has two or more distinct non-empty head_commit values among its own snapshots.
    • Basis reached_default_unattributed when this evaluation's default-branch observation produced a result, by any basis, whether or not that result was the one persisted (a write-once column already set by an earlier evaluation is not re-written, but the observation still counts here).
    • Basis branch_commits_unmerged otherwise — including when the observation was skipped or errored on this evaluation, which is a suppressed demotion rather than a reclassification.
  4. no_delivery_observed — at least one snapshot exists for this pair, every snapshot shows ahead = 0, and no session of the pair has two or more distinct non-empty head_commit values among its own snapshots. Basis no_commits_observed.
  5. unknown — no snapshot exists for this pair, at least one provider pull/merge request row exists, and none of them reports merged-and-not-detached. Basis provider_pr_unmerged.
  6. unknown — neither a snapshot nor a provider pull/merge request row exists. Basis no_observations.

Totality. The rules are exhaustive over every candidate pair, and this is checkable by inspection. Rules 2–4 cover every pair with at least one snapshot: rule 3's predicate and rule 4's predicate are exact complements of each other (some ahead > 0 or some session has ≥2 distinct heads of its own, versus all ahead = 0 and no session has ≥2 distinct heads of its own), and rule 2 is a strict subset of rule 3's domain taken first — a session with ≥2 distinct heads on the default branch has ≥2 distinct heads. Rules 5 and 6 cover every pair with no snapshot: provider rows exist, or they do not. Rule 1 crosses both and is taken first. No candidate pair can reach the end of the list unmatched, and no evaluation ever leaves delivery_outcome at NULL — NULL means only "no evaluation has been persisted for this row".

Rule 3 is the deliberate consequence of the outcome enum being fixed at four values. "Reached the default branch by an unobserved route" is real, and it is recorded in reached_default_at / reached_default_basis rather than being forced into pr_merge or direct_commit, both of which would assert a route that was not observed. delivery_basis carries the distinction the enum cannot.

Provider predicates

Each provider table is read through the predicates below. There is no shared schema, so each column is named per table.

Concept github_task_prs gitlab_task_mrs azure_devops_task_prs
Merged merged_at IS NOT NULL merged_at IS NOT NULL LOWER(TRIM(status)) IN ('completed','merged')
Detached detached_at IS NOT NULL never detached (no column; a GitLab detach deletes the row) never detached (no column)
Merge instant merged_at merged_at updated_at
Request number pr_number mr_iid pull_request_id
URL (delivery_ref) pr_url mr_url pull_request_url
Base branch base_branch base_branch target_branch

Azure's merged vocabulary is taken from the terminal-status set this repository already uses in internal/azuredevops/watch_cleanup.go (completed, abandoned, closed, merged); completed and merged are the merged states and every other value — including an unrecognised one — is not-merged. An unrecognised status is not an error and is never guessed at: the pair simply fails rule 1 and falls through. Azure carries no merge timestamp at all, so updated_at is its merge instant; this is a sort key only and is never written to reached_default_at.

Candidate pairs

A pair is a candidate when all three hold:

  1. its repository_id is non-empty and it appears in any of task_repositories, task_sessions.repository_id, or any provider pull/merge request table; and
  2. that repository_id resolves to a row in repositories; and
  3. its task_id resolves to a row in tasks.

A task with no repository produces no ledger row — absence means "not applicable" and is distinct from unknown.

Why condition 2 is not redundant. A non-empty repository_id is not a guaranteed foreign key anywhere it is read from: task_sessions.repository_id is TEXT DEFAULT '' with no FK to repositories, and the three provider tables declare their repository_id the same way (github_task_prs.repository_id is TEXT NOT NULL DEFAULT ''). A dangling non-empty value is therefore representable in the data, while task_delivery_ledger.repository_id is a real FK to repositories(id).

Without condition 2 such a pair is a candidate every sweep, every input read succeeds, the upsert fails the foreign key forever, no row is ever written, and — because "no ledger row" makes a pair unconditionally due — it is retried on every pass for the life of the database, consuming work and producing nothing.

So the pair is excluded from candidacy and counted, never attempted: delivery_ledger_pairs_missing_repository_total is set per pass to the number of such pairs. Counting is what keeps this consistent with "a pair is never silently skipped" — the exclusion is explicit, observable and bounded, rather than being an INNER JOIN that drops rows invisibly. A repository row that later appears makes the pair a candidate again with no special handling, because candidacy is recomputed every pass.

Why condition 3 exists, and why it is not implied by condition 2. The hazard is structurally identical and it is reachable through a different table, so guarding only the repository side leaves the same failure loop open on the task side. github_task_prs.task_id is TEXT NOT NULL with no FK to tasks, and no code path deletes github_task_prs rows when a task is hard-deleted — the GitHub task-deleted subscriber prunes PR watches and credentials only. (Azure DevOps does clean up, with a DELETE FROM azure_devops_task_prs WHERE task_id = ?; GitHub does not, and the divergence is why this cannot be reasoned about per provider.) Because the provider tables are a declared candidacy source in condition 1, an orphaned GitHub pull request row yields a candidate pair whose task row is gone, while task_delivery_ledger.task_id is a real FK to tasks(id).

Three separate things break if such a pair is admitted, and none of them is recoverable at build time from the rest of this document:

  • Its upsert fails the tasks(id) foreign key on every pass, forever, exactly as in the repository case — and because every failed upsert increments delivery_ledger_write_errors_total, the counter this spec designates as the primary migration-failure signal would climb continuously on a completely healthy schema. That does not merely add noise; it destroys the one signal a /debug/vars reader is told to watch, by making its resting value non-zero for an unrelated reason.
  • The sweep order under Ordering is tasks.created_at ascending, then tasks.id. A pair with no task row has neither value. An INNER JOIN would drop it silently, which contradicts "a pair is never silently skipped"; a LEFT JOIN would sort a NULL in a position neither dialect fixes.
  • workspace_id is NOT NULL and is defined as denormalized from the task. With no task row there is no value to denormalize and no honest default.

So the pair is excluded from candidacy and counted, never attempted, on the same terms as condition 2: delivery_ledger_pairs_missing_task_total is set per pass to the number of candidate pairs rejected because their task_id matched no tasks row. It is a separate gauge from the repository one rather than a combined "unresolvable pairs" count, because the two have different causes and different owners — a missing repository points at repository lifecycle, a missing task points at provider-row cleanup — and a single number would tell an operator neither. A tasks row that later appears (or a provider row that is finally pruned) changes the count on the next pass with no special handling, because candidacy is recomputed every pass.

Note that both conditions are different from soft-deletion. A soft-deleted repository (repositories.deleted_at non-NULL) still has a row, so its pairs remain candidates and are governed by the freeze under Persistence guarantees; a missing row is not a candidate at all. The two must not be collapsed: one retains its ledger rows, the other never had any. An archived task likewise still has a row and is still evaluated (Persistence guarantees); only a hard-deleted task removes the row, and its ledger rows go with it by cascade.

Degraded evaluation

When repositories.default_branch is empty — or NULL, which normalizes to empty — every default-branch-dependent step is suspended: rule 2, the ancestry observation, and the provider_pr_merged default-branch observation. All three are suspended, not just the first two, because the branch comparison is exact: a provider row whose own base branch column is empty would compare equal to an empty default_branch and permanently set the write-once reached_default_* triple during an evaluation that is blind to what the default branch actually is. That is the one outcome degraded mode exists to prevent, and it is why Classification's normalization states that an empty default_branch never matches any branch.

Rule 1 itself still runs — whether a pull request merged does not depend on knowing the default branch — so a degraded pair can still be classified pr_merge. Only the observation derived from that row's base branch is suspended.

Classification still runs and still matches exactly one rule, but the result is degraded: delivery_basis records default_branch_unknown in place of the matched rule's own basis, and evidence_rank is 1 regardless of which rule matched. delivery_outcome is whatever the matched rule produced. delivery_ref is NULL, per Ordering.

Recording the degradation rather than the rule is deliberate: "this pair was classified blind to the default branch" is the fact a consumer must not miss, and rank 1 guarantees the degraded row is replaced by any later real evaluation once default_branch is populated.

Rank 1 admits more than one outcome, and that has consequences the builder must not have to derive. Because every degraded evaluation lands at rank 1 whatever rule matched, two successive degraded evaluations of the same pair can carry different outcomes at the same rank. Worked example, which is reachable and not hypothetical: a degraded pair with a merged pull request matches rule 1 and stores pr_merge / default_branch_unknown at rank 1; the pull request is later detached, so rule 1 no longer matches, every snapshot reports ahead = 0, rule 4 matches, and the pair re-evaluates to no_delivery_observed / default_branch_unknown — also rank 1.

The rules that govern that write are:

  • The write happens. The classification columns are written at equal rank, so delivery_outcome becomes no_delivery_observed. A degraded row is a blind placeholder and must track its inputs; freezing it at the first outcome it ever saw would be worse.
  • It is not a suppressed demotion and delivery_ledger_demotions_suppressed_total does not increment — the rank did not fall, and that counter means what it says.
  • It is not silent: delivery_ledger_degraded_outcome_changed_total increments whenever an equal-rank write at rank 1 changes delivery_outcome, so a pair thrashing while blind is visible rather than being invisible in both counters.
  • updated_at advances, because a classification column changed.

None of this applies at ranks 2–8, where the rank is injective and an equal-rank write cannot change the outcome. See Ordering, idempotency, concurrency, "Equal-rank writes".

Default-branch observation

reached_default_at may be set by any of the four bases in Basis vocabulary. It is write-once and monotonic: once set it is never cleared, moved, or overwritten by a later observation with a different basis.

Basis precedence within one evaluation. A single evaluation can satisfy more than one basis at once — a merged pull request whose base branch is the default branch, snapshots committing on the default branch, and a positive ancestry check are not mutually exclusive. Because the triple is write-once, whichever basis is chosen is permanent, so the choice is fixed here and is not the builder's:

Precedence Basis reached_default_ref written
1 (strongest) provider_pr_merged the URL column of the row selected by the default-branch observation ref rule below — not the pr_merge ref rule
2 default_branch_commit the commit SHA selected by the direct_commit ref rule under Ordering
3 ancestor_of_default the commit SHA submitted to the ancestry check, selected by Which commit below
4 (weakest) push_webhook_default defined for a later card; nothing writes it here

The evaluation computes every basis it can, then writes the highest-precedence one that produced a result. The order is by strength of attribution: a merged provider request names the route, a default-branch commit names the act, ancestry names only the fact of arrival.

The default-branch observation ref rule. This is a different row set from the pr_merge ref rule under Ordering, and conflating the two writes the wrong ref into a write-once column. The pr_merge ref rule selects among the pair's merged, non-detached provider rows with no base-branch filter, because rule 1 asks only whether a pull request merged. The observation asks a strictly narrower question — whether work reached the default branch — so it selects among the pair's merged, non-detached provider rows whose base branch equals the repository's default_branch, and then applies the same tiebreak keys in the same order (earliest merge instant, then request number ascending, then provider name ascending in the order azure_devops, github, gitlab, then that provider's scope column ascending).

The two rules therefore agree whenever every merged row targets the default branch, and diverge exactly when they should. Concretely: a pair with a merged pull request into a release branch and a later merged pull request into main selects the release-branch row for delivery_ref (it merged first, and rule 1 does not care where) and the main row for reached_default_ref. Selecting the release-branch URL for the observation would permanently record, in a write-once column, a pull request that never reached the default branch.

Because both rules resolve through named columns with total tiebreaks, repeated evaluations of unchanged inputs select the same ref, and no new tiebreak is introduced.

This precedence governs only which basis is offered to the write-once triple. Whether it is stored is decided by Monotonic evidence: if reached_default_at is already non-NULL, none of the three columns is written, regardless of precedence.

Its value is the UTC instant at which the observation was first made — the evaluation's own clock reading — not the snapshot's created_at, not the provider's merged_at, and not the commit's author or committer date. Only the observation instant is available for all four bases, and a column whose source silently varies by basis cannot be compared across rows.

The absence of reached_default_at never means "did not reach the default branch". Ancestry in particular produces false negatives for every squash-merged branch — see Evidence, finding 2.

Ancestry precondition. An ancestry check is attempted only when at least one of the pair's sessions shows two or more distinct non-empty head_commit values among its own snapshots — that is, only when some single session's head actually moved and that session therefore authored commits. The grain is per session, never pooled across the pair; see Classification, "Head movement is a per-session fact". Pooling would let two sessions that each inherited a different branch satisfy the precondition without either one authoring anything, which is the precise failure this precondition exists to prevent. This precondition is load-bearing, not defensive. A session that produced nothing reports its inherited base commit as its head, and that commit is already on the default branch's history, so an ungated --is-ancestor returns true and would permanently set a write-once column on a pair that delivered nothing. ahead cannot substitute for this test: per Evidence finding 1 it is base-relative, so an inherited branch reports ahead > 0 with a head that never moved.

When the precondition is not met the check is skipped, delivery_ledger_ancestry_skipped_total increments, and nothing is written.

Which commit. The head submitted to the check is selected from the snapshots of the session(s) that satisfied the moving-head precondition — that is, only sessions showing two or more distinct non-empty head_commit values among their own snapshots contribute candidate heads — and, within that set, only from snapshots whose head_commit is non-empty after the normalization in Classification. A NULL, empty or whitespace-only head is not a candidate for selection at all, and a snapshot carrying one is skipped over rather than winning on recency. Among that doubly filtered set, the head is the head_commit of the snapshot with the greatest created_at; ties are broken by the lexicographically greatest head_commit among those sharing that created_at.

The filter is restated here rather than inherited by implication because the ordering key is created_at and the empty value would otherwise win whenever it is newest: a session that recorded a final snapshot with no head would send an empty string to git merge-base --is-ancestor, whose result is meaningless and whose failure would be counted as an ancestry error rather than as the selection bug it is.

The session restriction is load-bearing, and an earlier revision of this document had it backwards. That revision said the selection was "across the pair's snapshots, not restricted to the session that satisfied the precondition", on the reasoning that the precondition decides whether to check while recency decides what to check. That reasoning established only that a non-empty head exists somewhere across the pair. It never established that pooling every session's snapshots is safe, and it is not.

The failure it admits is the precondition's own, one step later. A session that authored nothing reports its inherited base commit as its head, and that commit is already on the default branch's history. Give a pair one authoring session on an unmerged feature branch and one idle session sitting on the default branch, and the idle session's snapshot can carry the later created_at — so pair-wide recency selects the inherited head, git merge-base --is-ancestor returns true trivially (it never diverged, so it is already an ancestor), and the write-once reached_default_* triple is permanently set with a ref naming a commit the pair did not produce. Rule 3 then reads that observation and promotes the classification from branch_commits_unmerged to reached_default_unattributed for the life of the row.

That is precisely the false positive Ancestry precondition above exists to prevent. The precondition closes it at the gate, by refusing to check at all unless some session's head moved; restricting selection closes the same hole at selection, which the gate alone does not reach. Both are required, because the gate and the selector can otherwise resolve to different sessions.

The restricted set is still never empty when a check is attempted: the precondition requires some session to hold two or more distinct non-empty heads, and that session's snapshots are exactly what the restriction keeps.

Which checkout. The repository's local checkout is repositories.local_path, and it is never read from the column directly. The behaviour this card requires is exactly what resolveRepositoryLocalPath in internal/task/service/repository_discovery.go already implements: reject an empty repository ID, load the repository row, reject an empty local_path, resolve the stored path through the explicit local-repository path resolution, and reject a stored path that canonicalizes to a different location than the one saved. This card inherits those canonicalization and containment checks rather than restating them, because restating them is how they drift apart.

The seam, stated because that helper is not directly callable. resolveRepositoryLocalPath is an unexported method on the task service's Service type and depends on that service's repository-entity handle, so it cannot be invoked from the evaluator as written, and the evaluator does not belong inside the task service. The contract is therefore stated as a port, not as a function call:

  • The evaluator depends on a narrow, read-only, single-method port — repository ID in, validated absolute checkout path or error out — and on nothing else from the task tier.
  • That port is satisfied by the task service's existing resolution behaviour, exposed for this consumer. Whether it is exposed by exporting the method, by an adapter, or by a wrapper is an implementation choice, and the choice is free provided the validation performed is the existing one and is not reimplemented.
  • The evaluator must not take a dependency on the concrete task service type, and must not duplicate the canonicalization or containment logic. Duplicating it is the one outcome this paragraph exists to prevent: the copy would silently omit the "saved path resolves elsewhere" rejection, which is the check that stops a moved or substituted checkout from being read.
  • The port returns an error for every rejection above and never a sentinel empty path, so the caller has exactly one failure shape to handle.

A session worktree is never used: worktrees are per-session and transient, and the ancestry question is about the repository. When local_path is empty or the port returns an error — the ordinary case for a provider-cloned repository that has never been materialized locally — the check is an error under Failure modes, never a negative result.

Which default branch. Within that checkout the check resolves the default branch as refs/remotes/origin/<default_branch>, falling back to the local branch <default_branch> when the remote-tracking ref does not exist. If neither resolves, the check is an error under Failure modes — never a negative result.

Evaluation triggers and cadence

The periodic sweep is the sole guaranteed mechanism. Every pair is evaluated by the sweep, on the cadence below, and every acceptance criterion in this document is satisfied by the sweep alone. An implementation that ships only the sweep is complete against this spec.

Event triggers are permitted accelerators, not requirements. They reduce latency from up to one sweep interval to near-immediate; they add no outcome the sweep would not eventually reach, because evaluation is idempotent and the sweep predicate re-selects any pair whose inputs moved. Where a trigger is wired it must use an existing bus event:

Input that moved Event available today
A task session reached a terminal state events.TaskSessionStateChanged (task_session.state_changed)
A GitHub pull request row changed events.GitHubTaskPRUpdated (github.task_pr.updated)
A GitLab merge request row changed events.GitLabTaskMRUpdated (gitlab.task_mr.updated)

Two inputs have no event on the bus today: a git snapshot write, and an Azure DevOps pull request sync. They are named here so the absence is a recorded fact rather than a gap a builder discovers and improvises around — this card adds no new event type for them, and their latency is one sweep interval.

A trigger, where wired, runs the same evaluation as the sweep, asynchronously, and never inside the caller's transaction: a telemetry writer must not be able to fail or slow a session transition or a provider sync. A trigger that is dropped costs latency and nothing else.

Every evaluation reads all input sources for the pair. There is no partial evaluator: if any input query fails, the evaluation is abandoned under Failure modes rather than classifying from what it managed to read.

Sweep selection predicate

The freeze is evaluated first and overrides all three conditions. A pair whose repository has a non-NULL repositories.deleted_at is never due, whichever condition below would otherwise select it. This precedence is stated explicitly because two of the three conditions would otherwise contradict the freeze promised under Persistence guarantees: a soft-deleted repository's pair with no ledger row would be unconditionally due forever, and one with reached_default_at IS NULL would be selected by stale refresh every 24 hours. Restoring the repository (clearing deleted_at) moves repositories.updated_at, which is itself a due source, so a restored pair resumes evaluation on the next sweep without special handling.

A candidate pair is due when the freeze does not apply and any of the following three conditions holds:

  • it has no ledger row — a pair with no row is unconditionally due, on every sweep, until one is written. This is what makes rule 6 reachable: a pair with no snapshot and no provider row has no input observation to compare against, and would otherwise never be selected. It also means the first sweeps after activation evaluate every pre-existing pair, once each, until each has a row. That is intended and is not the historical backfill excluded under Out of scope — see the reconciliation there; or
  • its last_evaluated_at is older than its most recent input observation, defined as the greatest of the following, ignoring sources with no rows:
Source Column
Git snapshots for the pair's sessions MAX(task_session_git_snapshots.created_at)
Sessions for the pair MAX(task_sessions.updated_at)
Task/repository membership MAX(task_repositories.updated_at)
The task row itself tasks.updated_at
The repository row itself repositories.updated_at
GitHub pull requests MAX(github_task_prs.updated_at)
GitLab merge requests MAX(gitlab_task_mrs.updated_at)
Azure DevOps pull requests MAX(azure_devops_task_prs.updated_at)

repositories.updated_at is in this table for a specific reason and must not be dropped as redundant with task_repositories.updated_at, which is a different table tracking task/repository membership. repositories is where default_branch and deleted_at live, so without this row: a Degraded evaluation row could never be promoted once default_branch was populated — contradicting the promotion this spec promises and the scenario that asserts it — and a soft-delete or restore would never re-evaluate the freeze described under Persistence guarantees.

tasks.updated_at is in this table so a task moved between workspaces re-evaluates and refreshes the denormalized workspace_id (see Data model). It re-selects pairs more often than the other sources do, which is accepted: evaluation is idempotent and its inputs are indexed reads, so a redundant evaluation costs a little work and changes nothing. The alternative — leaving workspace_id to drift — silently mis-scopes the extract that the column exists to serve.

Third condition:

  • Stale refresh. A pair whose reached_default_at is NULL is due when last_evaluated_at < (sweep pass start instant − 24 hours), even when no input moved. The comparison is strict, and the right-hand side is computed once per pass from the pass's own start instant rather than per pair, so every pair in one pass is tested against the same boundary and a long pass cannot let a pair drift past it mid-sweep.

    This exists because the two conditions above are both driven by Kandev's own tables, and the central question this feature asks — did this work reach the default branch? — can be answered by evidence that never touches them. Two concrete cases, both ordinary: a branch that merges weeks after its task's last session ends moves no row in any table above, and a provider-cloned repository whose local checkout did not exist when ancestry was first attempted moves no row when it is later materialized. Under the first two conditions alone, neither pair is ever re-examined, and its reached_default_at stays NULL forever — which would defeat the card for exactly the finished-card population the ledger exists to classify.

    It is bounded on both sides. It applies only while reached_default_at is NULL, so a pair that has been observed reaching the default branch is never re-swept by this condition — the column is write-once, so there would be nothing to gain. And at 24 hours it is far longer than the 5-minute sweep interval, so it adds at most one evaluation per unresolved pair per day and cannot interfere with the "not selected again on the next sweep" behaviour asserted in Scenarios.

    This condition also subsumes ancestry retry, which is why no separate retry state is persisted: an ancestry check that errored left reached_default_at at NULL, so the pair is refreshed a day later and the check is attempted again. Failure modes relies on this — an ancestry error is explicitly not an abandonment, so last_evaluated_at advances and only this condition brings the pair back.

When the ledger itself cannot be read

All three due conditions read task_delivery_ledger: condition 1 asks whether a row exists, conditions 2 and 3 compare last_evaluated_at and test reached_default_at. So dueness cannot be computed at all when the ledger table is missing or unreadable — which is precisely the state a swallowed migration leaves behind. Left unstated, the natural implementation is a single query joining candidates to the ledger, that query errors, the pass selects nothing, no upsert is ever attempted, and delivery_ledger_write_errors_total stays at 0 — silencing the counter this document designates as the primary migration-failure signal, and making the missing-table scenario under Scenarios unreachable. That is the same defect as the withdrawn "the stall detector fires" claim, one signal over, so it is closed here rather than left to be discovered.

The contract is therefore a two-stage selection with a defined fallback:

  1. Candidacy is computed without touching the ledger. The candidate set comes from task_repositories, task_sessions, the three provider tables, tasks and repositories — all of which are Candidate pairs inputs. This stage never reads task_delivery_ledger, so a missing ledger cannot suppress it.
  2. Dueness is then computed by reading the ledger rows for those candidates. If that read succeeds, the three due conditions apply exactly as written. If it fails — the table does not exist, or the read errors for any other reason — the pass treats every candidate as due, which is the same answer condition 1 gives for a pair with no ledger row, and proceeds to evaluate and upsert each one.

The fallback is what makes the designated signal true: each of those upserts then fails and increments delivery_ledger_write_errors_total, and delivery_ledger.write_failed is logged once for the pass, exactly as Failure modes promises. The sweep additionally logs once per pass at WARN with the message delivery_ledger.dueness_unavailable, carrying the error and the candidate count, so the fallback is distinguishable from a genuine first-sweep-after-activation pass in which every pair really is new.

Three properties bound it, and none of them is optional:

  • A failed dueness read is not an input-query failure. It does not abandon evaluations under Failure modes and does not increment delivery_ledger_evaluation_errors_total. The ledger is this feature's output, not one of the inputs a classification is computed from, and conflating the two would abandon every pair and re-silence the counter by a different route.
  • The fallback is idempotent and self-limiting. When the read failure is transient and the table does exist, the pass performs one redundant round of evaluations whose upserts succeed; monotonic evidence and the high-water rules make each of those a no-op in value, so the only cost is work. The next pass reads dueness normally.
  • The freeze and the candidacy conditions still apply. The fallback widens dueness only. A soft-deleted repository's pairs are still never due (see the freeze above), and a pair failing conditions 2 or 3 of Candidate pairs is still excluded and counted rather than attempted — so the fallback cannot turn an unresolvable pair into a write attempt.

Which instant last_evaluated_at records. The value written is the UTC instant at which the evaluation began reading its inputs. It is not the instant the upsert committed, and this is not a stylistic preference — the natural implementation is wrong and fails silently.

Take an evaluation that reads its inputs at T0, and suppose an input row for the same pair changes at T1 while the evaluation is still running, with the upsert committing at T2. Writing last_evaluated_at = T2 records that the pair was evaluated after the T1 change when in fact it was evaluated before it. The dueness test is last_evaluated_at < MAX(input.updated_at), so T1 < T2 means the pair is not due and the T1 change is masked permanently. Writing last_evaluated_at = T0 leaves T1 > T0, so the pair stays due and the next sweep sees the change.

This is a lost update, and the case where it bites is the ordinary one rather than a rare race: the last snapshot a finishing session writes is frequently the last input movement that pair will ever have, so a masked T1 leaves the pair pinned at whatever the evaluation computed one moment too early — for example no_delivery_observed on a pair whose final snapshot reported ahead > 0. Event triggers cannot rescue it, because they are explicitly optional accelerators and an implementation that ships only the sweep is complete against this spec.

The high-water rule under Monotonic evidence still applies on top of this: the stored last_evaluated_at never moves backwards, so a slow evaluation that began before a fast one cannot rewind the column.

The sweep evaluates every due pair in the order given under Ordering. It has no result cap: a pair is never silently skipped. If a sweep is interrupted, the next sweep resumes from the same ordering and re-evaluates whatever it did not reach; because evaluation is idempotent, partial completion is safe.

Boundary values, all fixed by this spec so they are not invented downstream:

Value Setting
Sweep interval 5 minutes, completion-relative: the next pass starts 5 minutes after the previous pass finished, not 5 minutes after it started
First sweep runs at boot, immediately after the schema probe under Activation points, with no initial delay
Concurrent sweep passes never. A single sweep actor runs at most one pass at a time; a pass that overruns the interval delays the next one rather than overlapping it
Refresh interval (stale-refresh due condition) 24 hours
Ancestry call timeout 10 seconds per (task, repository)
Ancestry calls per evaluation at most 1 per pair
Ancestry admission class subproc.GitBackground
evaluation_seq initial value 1 on insert
Stall threshold for the data-side health signal 15 minutes, i.e. three missed sweeps
Sweep result cap none

The three scheduling rows are fixed here rather than left to the implementation because each one changes observable behaviour. Completion-relative with no overlap is what makes the per-pass gauges (delivery_ledger_sessions_unattributed_total, delivery_ledger_pairs_missing_repository_total) well defined — "set at the end of every pass" has no single meaning if two passes can be in flight, and two overlapping passes would race on the same rows, making evaluation_seq advance twice for one round of inputs and letting the write-once reached_default_* triple be decided by scheduling. A fixed-rate timer would allow exactly that whenever a pass ran long. First sweep at boot matters because the alternative — waiting one interval — leaves a freshly migrated database with an empty ledger for five minutes while publishing an activation instant that says the mechanism is live.

A pair whose task still has running sessions is evaluated normally. Its outcome may be promoted by any later evaluation; there is no "wait until the task is finished" gate, because the rank order makes early classification safe.

API surface

No new HTTP route, WebSocket message, or MCP tool, and no new frontend state or user-visible copy. The ledger is a telemetry table read by the writer-health signals below and by the external extract described under Out of scope. Existing task, session, and pull-request responses are unchanged.

Slice A changes two existing day-grain dashboard response shapes additively: AgentRunActivityDay gains skipped and unclassified, and AgentSuccessRateDay gains unclassified. No field is removed or renamed, no run-grain DTO changes, and no new route is added. That contract is specified under Office run outcome § Response shapes. The values those shapes carry do change for pre-activation days, which is a behavioural change to an already-rendered chart even though no frontend file changes; the new unclassified field is what makes that change legible in-band.

Persistence guarantees

  • The ledger survives backend restarts. last_evaluated_at in the table is the authoritative writer-liveness record; no in-process state is required to detect a stalled writer.
  • reached_default_at and first_classified_at are write-once for the life of the row.
  • Deleting a task deletes its ledger rows by cascade. Archiving a task does not: archived tasks continue to be evaluated.
  • Soft-deleting a repository (repositories.deleted_at) freezes evaluation for its pairs. Existing rows are retained and last_evaluated_at stops advancing.
  • Migrations follow ADR 0027: nullable ADD COLUMN then idempotent backfill, replay-safe on SQLite and Postgres, classified through db.IsDuplicateColumnError / db.IsAlreadyExistsError rather than a local string match.

Ordering, idempotency, concurrency

Ordering. A sweep evaluates due pairs ordered by tasks.created_at ascending, then tasks.id ascending, then repositories.id ascending.

Within a pair, evidence is selected deterministically without relying on a random identifier or a dialect-specific row number:

  • pr_merge ref: among the pair's merged, non-detached provider rows, the one with the earliest merge instant (per Provider predicates: merged_at for GitHub and GitLab, updated_at for Azure DevOps); ties broken by request number ascending (pr_number / mr_iid / pull_request_id); further ties by provider name ascending in the order azure_devops, github, gitlab; and further ties by that provider's scope column ascending — azure_devops_task_prs.azure_repository_id, gitlab_task_mrs.project_path, and github_task_prs.owner then github_task_prs.repo. The ref written is that row's URL column.

    The scope column is not decoration, but it is load-bearing for two of the three providers, not all three, and the difference is stated exactly because the uniqueness keys are quoted here and a reader can check them:

    • gitlab_task_mrs is UNIQUE(task_id, repository_id, project_path, mr_iid) and azure_devops_task_prs is UNIQUE(task_id, repository_id, azure_repository_id, pull_request_id). Both keys include the scope column, so one pair can legitimately hold two rows from the same provider with the same request number and different URLs. For these two the scope tiebreak is what makes the selection deterministic, and dropping it would leave exactly the ambiguity this section exists to rule out.
    • github_task_prs is UNIQUE(task_id, repository_id, pr_number), which does not include owner or repo. That constraint already makes (pair, pr_number) unique for GitHub, so the collision the scope key resolves cannot occur there and the owner-then-repo tiebreak is redundant for GitHub. It is retained anyway so the ordering is total by the same construction for every provider, which keeps the rule checkable by inspection.

    No schema change is implied. The GitHub key is correct as it stands; the redundancy is in the tiebreak, not in the constraint. Do not "fix" the uniqueness key to match the other two — widening it would let a pair hold two GitHub rows with the same pr_number, which the current constraint deliberately prevents.

  • direct_commit ref: restricted to the snapshots of the session(s) that satisfied rule 2's own predicate — sessions holding two or more distinct non-empty head_commit values, among their own snapshots, whose branch equals the repository's default_branch — and, within that set, among the pair's default-branch snapshots whose head_commit is non-empty after the normalization in Classification, those sharing the greatest created_at, and of those the lexicographically greatest head_commit. The non-empty filter is applied before the created_at comparison, not after, so a newer empty-headed snapshot cannot displace a real commit SHA and cannot be written into delivery_ref — or, through the default_branch_commit basis, into the write-once reached_default_ref. Rule 2 requires two or more distinct non-empty heads on the default branch for this basis to be reachable at all, so the restricted set is never empty here.

    Why the session restriction, stated because the same defect appears twice in this document. This is the direct_commit twin of the restriction argued at length under Default-branch observation, "Which commit", and it exists for the same reason. Rule 2 is earned per session, but an earlier revision selected the ref pair-wide, so a second session merely sitting on the default branch could supply a newer snapshot whose head is its inherited tip. That head would then be written as delivery_ref — and, because default_branch_commit is basis precedence 2, into the write-once reached_default_ref as well — permanently naming a commit that the session which actually earned the classification did not author. Restricting the candidate set to the session(s) that satisfied rule 2 is what keeps the ref attributable to the work that produced it.

  • Every other rule writes delivery_ref as NULL. Rules 3, 4, 5 and 6 — branch_commits_unmerged, reached_default_unattributed, no_commits_observed, provider_pr_unmerged, no_observations — and any Degraded evaluation row identify no single artifact that carries the delivery, so there is nothing honest to name. In particular rule 5 does not write the unmerged pull request's URL: that row is evidence the pair did not deliver, and putting it in a column defined as "the identifying evidence" for a delivery would invite a consumer to read it as one. The head commit is likewise not written, because a branch head is not a delivery.

    This completes the ref rules over all six classification rules, which is what makes the equal-rank no-op claim under Monotonic evidence true for every rule rather than only for the two that name a ref.

  • observed_branch_commits incoming value: the greatest ahead across the pair's snapshots after the NULL/negative normalization in Classification, which needs no tiebreak. When the pair has no snapshots at all the incoming value is NULL, not 0 — the greatest of an empty set is not zero, and 0 would assert "we looked and there were no commits" for a pair where nothing was ever observed, which is the exact NULL-versus-0 confusion this feature exists to prevent. Combined with the high-water rule, a NULL incoming value can never lower a stored one: a pair that had snapshots and then lost them keeps its high-water mark.

Idempotency. Evaluation is a pure function of the observed inputs. Re-running an evaluation whose inputs have not changed must leave delivery_outcome, delivery_basis, delivery_ref, evidence_rank, observed_branch_commits, reached_default_at, reached_default_basis, reached_default_ref and first_classified_at byte-identical and must not advance updated_at. Only last_evaluated_at and evaluation_seq advance.

Monotonic evidence. The classification columns move only up the Evidence rank ladder. Comparing the incoming rank to the stored rank gives exactly three cases, and every column's behaviour is fixed in all three:

incoming rank > stored incoming rank = stored incoming rank < stored
delivery_outcome, delivery_basis, delivery_ref, evidence_rank written written not written
first_classified_at written if currently NULL written if currently NULL not written
observed_branch_commits high-water (below) high-water high-water
reached_default_at / _basis / _ref written only if reached_default_at is currently NULL, as one unit same same
last_evaluated_at high-water (below) high-water high-water
evaluation_seq stored + 1 stored + 1 stored + 1
workspace_id written written written
delivery_ledger_demotions_suppressed_total — — incremented
delivery_ledger_degraded_outcome_changed_total — incremented only at rank 1 when delivery_outcome changed —

workspace_id is written unconditionally in all three columns of that table, including the demotion case. It is not evidence about delivery and the rank guard has no business gating it: a pair that moved workspace has moved workspace regardless of what its classification did.

Equal-rank writes, stated separately for ranks 2–8 and for rank 1. The two cases are genuinely different and collapsing them is what made an earlier revision of this section wrong.

At ranks 2–8. Equal rank implies the same (outcome, basis), because the rank is injective there (Evidence rank). So delivery_outcome and delivery_basis are always a no-op in value. delivery_ref is not guaranteed unchanged: the ref names the currently-selected evidence, and the evidence set can move while the rank does not — a pair already at rank 8 that acquires a second merged pull request with an earlier merge instant re-selects to that row's URL. That is intended, not a defect: the ref must name evidence that exists. When the ref changes, updated_at advances and delivery_ledger_rows_written_total increments; when nothing changed, neither does.

At rank 1. Equal rank does not imply the same outcome, because rank 1 is deliberately non-injective. The classification columns are still written, and the outcome therefore tracks the current inputs — which is the correct behaviour for a degraded row, since it is a blind placeholder and pinning it to a stale outcome would be worse than letting it move. But this write can change delivery_outcome without any demotion being recorded, so it is counted rather than left silent: an equal-rank write at rank 1 that changes delivery_outcome increments delivery_ledger_degraded_outcome_changed_total (see Writer health).

The concurrency consequence at rank 1 is stated honestly rather than papered over: two evaluators racing at rank 1 with different outcomes are last-writer-wins, so arrival order decides which outcome is stored. This is bounded and self-repairing — the next sweep recomputes from the same inputs and converges, and the whole row is superseded by any rank ≥ 2 evaluation once default_branch is populated. The "converge regardless of arrival order" guarantee below therefore holds for ranks 2–8 and is explicitly not claimed for rank 1.

High-water columns, stated as behaviour and not as a SQL function. observed_branch_commits and last_evaluated_at take the greater of the stored and incoming values, unconditionally, in all three rank cases, with a NULL stored value treated as lower than any non-NULL incoming value. observed_branch_commits therefore never decreases when a session — and with it, by ON DELETE CASCADE, its snapshots — is deleted. It is a stored high-water mark and not a recomputed aggregate; this is why rule 3's predicate reads the freshly observed ahead values rather than this column.

That behaviour is the contract. The obvious SQL spelling does not implement it, and the difference is silent, so it is called out rather than left to be discovered:

  • SQLite's two-argument max(X, Y) returns NULL if either argument is NULL, so max(observed_branch_commits, excluded.observed_branch_commits) erases a real value the first time either side is NULL — the opposite of a high-water mark.
  • PostgreSQL has no two-argument max at all; the equivalent is GREATEST, whose NULL handling differs again.

So the implementation must make the NULL handling explicit — coalescing to a floor before comparing, and using each dialect's own spelling — rather than relying on a bare MAX. This is the one place where Concurrency's "never by a read-then-write in application code" needs qualifying: the rule stays, and the NULL handling belongs inside the statement, not in a Go read-modify-write.

The same trap applies to the rank guard itself. x >= NULL evaluates to NULL on both dialects and a CASE on it takes the ELSE branch, so a nullable evidence_rank would silently suppress every write against a row that had one. That is why evidence_rank is declared NOT NULL DEFAULT 0 in the data model.

Both dialects are in scope for this statement. ADR 0027 governs the migrations, and the exemption recorded under Office run outcome covers only the pre-existing SQLite-only RunCountsByDayForAgent; new code added by this card is expected to run on SQLite and PostgreSQL alike.

A demotion is never written. If a pull request is detached after it merged, or a snapshot is deleted, the row keeps its stronger evidence and the suppression is counted. This is what makes the ledger safe: an evaluation that runs after the evidence has been removed can never erase what an evaluation that could see it recorded.

Concurrency. The ledger is written with a single INSERT ... ON CONFLICT (task_id, repository_id) DO UPDATE statement. Every rule in the table above is expressed inside that statement's SET clause — as CASE WHEN excluded.evidence_rank >= task_delivery_ledger.evidence_rank THEN ... END for the classification columns (sound precisely because evidence_rank is NOT NULL), CASE WHEN task_delivery_ledger.reached_default_at IS NULL THEN ... END for the write-once triple, and the explicitly NULL-handled, dialect-appropriate greatest-of expression described under Monotonic evidence for the high-water columns — never by a read-then-write in application code. Consequences, all of which are contract:

  • Two evaluators racing on the same pair with different ranks — say one computing rank 1 and one rank 8 — converge on rank 8 regardless of arrival order, and neither can lose the other's write. This is the rank guard doing its job and it holds for every unequal pair of ranks.
  • Two evaluators racing at equal rank 1 with different outcomes are the one exception and are not order-independent: rank 1 is deliberately non-injective, so the last writer's outcome is stored. This is bounded and self-repairing — see Degraded evaluation and "Equal-rank writes" above. It is called out here so the bullet above is not read as a guarantee it does not make.
  • Two evaluators supplying a first default-branch observation concurrently: whichever statement commits first writes all three of reached_default_at, reached_default_basis and reached_default_ref; the second sees a non-NULL reached_default_at and writes none of them. The triple is therefore always internally consistent — it is never assembled from two different observations.
  • evaluation_seq increments on every persisted evaluation, so a consumer can tell that the row was re-evaluated even when nothing changed.

Ancestry checks run through the existing class-aware git subprocess admission pool at class subproc.GitBackground — the sweep is background work and must not contend with user-facing git — with a bounded timeout, at most one per (task, repository) per evaluation.

Failure modes

  • An input query fails — a snapshot, session, membership or provider read returns an error. The evaluation for that pair is abandoned: no column is written, not even last_evaluated_at, so the pair stays due and is retried on the next trigger or sweep. delivery_ledger_evaluation_errors_total increments. A classification is never persisted from a partially-read input set.

  • The ledger upsert itself fails — the INSERT ... ON CONFLICT returns an error: the table does not exist because its migration was swallowed, a constraint is violated, or the write times out. This is a distinct failure from an input-query failure and must not be folded into it, because only this one indicates the ledger's own schema or storage is broken. No column is written, so the pair stays due and is retried on the next sweep. delivery_ledger_write_errors_total increments — this counter is the primary migration-failure signal and the one a /debug/vars reader should watch. The sweep logs once per pass at WARN with the message delivery_ledger.write_failed, carrying the error and the count of pairs that failed in that pass; once per pass rather than once per pair, so a missing table cannot emit one log line per candidate. A failing upsert does not abort the pass — the sweep continues to the next due pair, so one poisoned pair cannot stop the writer.

  • Ancestry cannot be computed — the repository has no readable local checkout, neither default-branch ref resolves, the head commit object is absent, or the git call fails or times out. No evidence is produced: reached_default_at stays NULL and the outcome is unaffected. delivery_ledger_ancestry_errors_total increments. The error is never persisted as a negative result, and it does not abandon the evaluation — the rest of the classification still runs.

  • The ancestry precondition is not met — fewer than two distinct non-empty head_commit values. The check is skipped, delivery_ledger_ancestry_skipped_total increments, and this is not an error.

  • A negative ancestry result is not evidence. It must not set any column and must not influence the outcome. Squash-merge makes it a routine false negative.

  • repositories.default_branch is empty — handled by Degraded evaluation: rules still run, delivery_basis is default_branch_unknown, evidence_rank is 1.

  • A session has an empty or NULL repository_id — the column is TEXT DEFAULT '' and not NOT NULL, so both forms occur; both normalize to empty. The session contributes to no pair, is excluded from the stall comparand, and is counted in delivery_ledger_sessions_unattributed_total. Three such sessions exist in the reference store.

  • head_commit is empty, whitespace-only, or NULL — it is not a distinct head value and is ignored.

  • repositories.default_branch is NULL — the column is TEXT DEFAULT '' and not NOT NULL. It normalizes to empty and takes the Degraded evaluation path, the same as an empty string. There is no third behaviour.

  • Azure DevOps has no merged_at — resolved by Provider predicates: merged is read from status, and updated_at is the merge instant used for ordering only. An unrecognised status is not-merged, so the pair falls through to a later rule rather than being guessed at.

  • The same pull request is attached to more than one task — this occurs in the reference store (PR #2418, two tasks). Each task's pair is classified independently and both may read pr_merge. The ledger does not deduplicate credit across tasks.

  • A migration fails — the migration runner logs at WARN and swallows the error (apps/backend/internal/db/migratelog.go, MigrateLogger.Apply), so a failed migration is invisible at boot. Three signals make it visible, and it is worth being exact about which one does the work, because an earlier revision of this document claimed "the stall detector fires" and that was false:

    • telemetry.delivery_ledger.activated_at is absent from kandev_meta, because the schema probe under Activation points found no table. This is the cheapest check and the one a consumer should make first.
    • delivery_ledger_write_errors_total climbs on every sweep pass and delivery_ledger.write_failed is logged, per the upsert-failure mode above. This is the signal that actually fires — and it fires only because of the dueness fallback under Sweep selection predicate, "When the ledger itself cannot be read". Without that fallback the missing table would break dueness computation first, the pass would select nothing, and this counter would stay at 0. The two statements are load-bearing for each other: do not implement one without the other, or this bullet becomes as false as the withdrawn stall-detector claim below.
    • delivery_ledger.dueness_unavailable is logged once for the pass, which is what distinguishes a broken ledger from a legitimate first sweep in which every candidate is genuinely new.
    • delivery_ledger_last_evaluated_unix publishes the unavailable sentinel -1, per Writer health.

    The stall detector specifically does not fire, and relying on it would have been the mistake: with the table missing there is nothing to take MAX(last_evaluated_at) over, and the empty-table rule would report delivery_ledger_stall_seconds = 0, which reads as healthy. That is why the stall signal is not the migration-failure signal and why the sentinel above exists.

Writer health

If this writer silently stops, nothing today would notice. Both signals below are required, because they fail in different ways.

In-process counters, following the subproc_* / routing_* precedent (apps/backend/internal/common/subproc/metrics.go), published at package init and exposed through the existing dev-mode /debug/vars handler:

Counter Increments
delivery_ledger_evaluations_total once per persisted evaluation, keyed by the delivery_outcome that evaluation computed. An abandoned evaluation persists nothing and is not counted here; it is counted only by delivery_ledger_evaluation_errors_total. A suppressed demotion is counted, keyed by the outcome the evaluation computed, not the one that stayed stored — otherwise a pair stuck in demotion looks idle.
delivery_ledger_rows_written_total once per upsert that changed at least one classification or observation column, i.e. exactly when updated_at advances. Not once per evaluation: a re-evaluation with unchanged inputs advances only last_evaluated_at and evaluation_seq, and counting it would make the counter a duplicate of evaluations_total.
delivery_ledger_demotions_suppressed_total once per upsert whose incoming rank was strictly lower than the stored rank.
delivery_ledger_evaluation_errors_total once per abandoned evaluation (an input query failed).
delivery_ledger_ancestry_errors_total once per ancestry check that was attempted and could not produce a result.
delivery_ledger_ancestry_skipped_total once per evaluation where the ancestry precondition was not met.
delivery_ledger_write_errors_total once per pair whose ledger upsert returned an error (see Failure modes). Kept separate from evaluation_errors_total because an input-query failure means the inputs are unreadable while this means the ledger's own schema or storage is broken — the migration-failure case. This is the counter that fires when a swallowed migration left the table missing.
delivery_ledger_degraded_outcome_changed_total once per equal-rank write at rank 1 that changed delivery_outcome (see Degraded evaluation). Rank 1 is deliberately non-injective, so such a write is neither a promotion nor a suppressed demotion and would otherwise be invisible in every other counter here.
delivery_ledger_pairs_missing_repository_total a gauge, not a running total, set per sweep pass for the same reason as sessions_unattributed_total: the count of candidate pairs the pass rejected because their repository_id matched no repositories row (see Candidate pairs).
delivery_ledger_pairs_missing_task_total a gauge, not a running total, set per sweep pass: the count of candidate pairs the pass rejected because their task_id matched no tasks row (see Candidate pairs, condition 3). Kept separate from the repository gauge because the two have different causes — a missing repository points at repository lifecycle, a missing task at provider-row cleanup — and a combined number would identify neither. A non-zero resting value here is a data-hygiene fact about orphaned provider rows, not a ledger fault, and specifically not a migration failure.
delivery_ledger_sessions_unattributed_total a gauge, not a running total. It is set — not incremented — at the end of every sweep pass to the count of distinct sessions with an empty repository_id that the pass encountered. It is the one value here whose natural reading is "how many are there", and incrementing per encounter would add the same three reference-store sessions on every 5-minute pass, turning a fact about the data into a number that only measures uptime. The _total suffix is retained for consistency with the subproc_* precedent.

Every counter is keyed and cadenced above so that two implementations produce the same numbers from the same inputs. A counter whose meaning is left to the writer is not a health signal.

The data-side stall signal. Counters reset to zero on restart, so a writer that stopped before the last restart looks healthy by counters alone. The stall signal is computed from the database instead, which is what makes it survive a restart:

  • Actor and cadence: the sweep, at the end of every pass, including a pass that evaluated nothing.

  • What it computes: MAX(task_delivery_ledger.last_evaluated_at) across the whole table, and as the comparand, MAX(task_sessions.updated_at) across only those sessions that could have produced work for the writer:

    Filter Column Why
    session attributes to a pair task_sessions.repository_id non-empty after normalization A session with no repository contributes to no pair, so it can never advance last_evaluated_at. Three such sessions exist in the reference store.
    repository still live the joined repositories.deleted_at IS NULL Pairs of a soft-deleted repository have last_evaluated_at frozen by design under Persistence guarantees.

    Each filter removes a session whose progress the writer is supposed to ignore. Without them the comparand advances on work the ledger is correctly not doing, and the signal reports a stall on a perfectly healthy writer — a false alarm, which is how a real one gets ignored.

    There is deliberately no archived filter, and the earlier "non-archived sessions" wording is withdrawn. It named no real column — task_sessions has no archived column in its CREATE TABLE or in any migration, and archival lives on tasks.archived_at — but more importantly it was wrong on the merits: Persistence guarantees states that archived tasks continue to be evaluated, so their sessions are work the writer really is expected to keep up with. Excluding them would have hidden a genuine stall on a board whose recent activity is mostly archived. The comparand covers every session the writer is responsible for, and nothing else.

  • What it publishes: two expvar Ints alongside the counters, delivery_ledger_last_evaluated_unix and delivery_ledger_stall_seconds. Two independent queries feed them — the ledger query (MAX(task_delivery_ledger.last_evaluated_at)) and the comparand query (the filtered MAX(task_sessions.updated_at)) — and either can fail or return an empty result on its own. The table below is total over both, in that order of precedence; states that look identical without a sentinel are given distinct ones:

    State ..._last_evaluated_unix ..._stall_seconds Meaning
    The ledger query errored (table missing or unreadable) -1 -1 The ledger is unavailable. Distinct from healthy-and-empty, which is the whole point of the sentinel. Takes precedence over every row below: if the ledger cannot be read, the comparand's state is irrelevant.
    The ledger query succeeded and the table is empty 0 0 No pair has been evaluated yet. Not a stall: an empty table is not an infinite stall.
    Ledger rows exist and the comparand query errored MAX(last_evaluated_at) -1 The stall figure is unknown, not zero. The last-evaluated value is real and is still published, because it was read successfully; only the derived figure is unavailable.
    Ledger rows exist and the comparand set is empty (no session survives the filters) MAX(last_evaluated_at) 0 Not a stall. There is no work the writer is responsible for keeping up with, so it cannot be behind. This is the ordinary state of a fresh database whose pairs come from task_repositories and whose sessions have not run yet.
    Ledger rows exist and the comparand is present MAX(last_evaluated_at) as Unix seconds comparand minus it, in seconds, clamped at 0 The real values.

    The comparand-empty and comparand-error rows are separated deliberately: an empty comparand is a true statement about the data and must read healthy, while a failed comparand read is an absence of information and must not be able to masquerade as healthy. Publishing 0 for both would let a repeatedly failing comparand query report a permanently healthy writer.

    When the ledger query errors the sweep logs once per pass at WARN with the message delivery_ledger.unavailable; when the comparand query errors it logs once per pass at WARN with delivery_ledger.comparand_unavailable. Without the -1 sentinels a missing table would publish 0/0, which is the healthy-and-empty state — a broken ledger would be indistinguishable from a fresh one, which is exactly the inversion this section exists to prevent.

    A consumer reading delivery_ledger_stall_seconds therefore treats -1 as "no measurement", never as a magnitude, and never compares it against the 15-minute threshold.

    Because all of these are recomputed from the database on every pass, the first sweep after a restart republishes the true values.

  • What "fires" means: when delivery_ledger_stall_seconds exceeds the 15-minute threshold, the sweep logs once per pass at WARN with the message delivery_ledger.stalled, carrying both values as fields.

What these signals do NOT cover, stated as an accepted residual rather than left to be discovered. Every signal in this section is published by the sweep being monitored. A sweep goroutine that panics on its first pass, or is never started, publishes nothing at all — so the counters keep whatever value they last held (typically 0, which reads as healthy) and no WARN is ever emitted. This is the literal "the writer silently stops" case this section opens with, and no in-process signal can close it: a process cannot report its own death.

It is accepted rather than solved, for a specific reason that makes the residual small. The authoritative liveness record is task_delivery_ledger.last_evaluated_at, which lives in the database, not in the process. Any consumer — the external extract above all, which is the reason this table exists — can compute now() - MAX(last_evaluated_at) for itself without the sweep participating at all, and that computation is correct whether the sweep is running, wedged, or was never started. The in-process signals are a convenience for a live /debug/vars reader; the data-side record is the ground truth, and this spec's obligation is to keep it accurate. No acceptance criterion asserts the in-process signals detect a dead sweep, because they cannot.

Office run outcome

A second, delimited contract in the same card, because it is the run-grain form of the same defect: a terminal status is not an outcome.

Call sites. Every call site that finishes a run records the outcome that describes it. The semantic label in the right-hand column is authoritative; the line number is a pointer (see Citation convention).

Call site Outcome
office/service/scheduler_integration.go:218 — agent not active agent_inactive
office/service/scheduler_integration.go:233 — pause gate (early check) workspace_paused
office/service/scheduler_integration.go:247 — idle skip idle_skipped
office/service/scheduler_integration.go:384 — pause gate (final check) workspace_paused
office/service/scheduler_integration.go:517 — task-tree hold task_tree_held
office/service/budget_admission.go:120 — admission budget_blocked/budget_unmeasurable
office/service/event_subscribers.go:408 — agent-completed for a task-bearing run processed
office/service/event_subscribers.go:512 — handleTasklessAgentCompleted processed

Checkout errors and checkout contention do not finish a run: a checkout error uses the normal retry/failure path, and a contention result requeues and retries. Neither reports work that did not run.

Reachability of :512. handleTasklessAgentCompleted resolves its run through GetClaimedTasklessRunForAgent, which requires status = 'claimed' and an empty payload.task_id. Taskless runs fail before they can remain claimed, so the lookup normally returns sql.ErrNoRows and the site is a no-op. It is mapped to processed because an AgentCompleted event may still arrive for a legacy claimed run, and it is retained so the mapping is total over the call sites that exist.

:408 is the path where an agent actually ran to completion and is the ordinary source of processed. Leaving any terminal site unset would grow unclassified forever after activation and falsify the guarantee that the activation point tells a consumer when unclassified stops growing.

Concurrency, retry and error behaviour for this column. Slice A does not introduce a transaction or a guard, because it must not change runs.status semantics:

  • outcome is written in the same UPDATE statement as status, so a run can never hold a terminal status with a stale outcome from a different transition.
  • Where that statement actually lives, stated precisely, because the obvious answer is wrong. transitionRunTerminal (office/service/scheduler_runs.go:52) contains no UPDATE; it pre-fetches the run, calls s.repo.FinishRun(ctx, id, status), and publishes OfficeRunProcessed. The single UPDATE runs SET status = ?, finished_at = ? WHERE id = ? lives one layer down, in internal/runs/repository/sqlite's func (r *Repository) FinishRun(ctx context.Context, id, status string) error — the only implementation of that method in the repository. Per Citation convention the statement to extend is the one inside the repository's FinishRun, because that statement's real home has a caller the service layer does not.
  • outcome becomes a parameter of both layers. The runs repository's FinishRun gains an outcome parameter alongside status and writes both in its one UPDATE; transitionRunTerminal gains the same parameter and forwards it. FinishRun (scheduler_runs.go:38) takes the value from the call-site table above and FailRun (:44) passes NULL. NULL is correct rather than a placeholder — a run that reached status = 'failed' is bucketed by RunCountsByDayForAgent on its status alone and never reaches the succeeded / skipped / unclassified buckets, so no value from the seven-value vocabulary is ever read, and inventing one asserts a classification nothing consumes. This matches ### runs.outcome: the seven-value claim describes the finished path; the failed path writes NULL in the same statement.
  • The second caller pair of the shared repository method, and what it passes. Changing that signature necessarily reaches office/scheduler.SchedulerService.FinishRun and .FailRun (internal/office/scheduler/run_processing.go), which call the same repo.FinishRun without going through transitionRunTerminal or the eight-site table. Both pass NULL and neither is added to the call-site table, so a builder does not need to guess or "improve" it into a vocabulary value:
    • SchedulerService.FailRun writes RunStatusFailed. It is reached in production (via that package's own retry path), and NULL is the same value FailRun passes at the service layer, for the same reason — a failed run is bucketed on status alone.
    • SchedulerService.FinishRun writes RunStatusFinished and has no production caller at the time of writing. It is a dormant parallel copy of the service-layer method. It passes NULL because there is no outcome to describe: no call site means no semantic label, and inventing processed for it would be the exact defect this slice exists to remove — a run in which nothing is known to have happened counted as a success. NULL routes it to unclassified, which is the honest bucket for a path whose meaning is unestablished.
    • Consequently the activation guarantee below is scoped to the eight sites and is not weakened by these two: neither is a terminal site this card classifies, and if SchedulerService.FinishRun is ever wired to something real, the card that wires it owns choosing its outcome and adding it to the table. Deleting the dormant method is not in scope here (see ## Out of scope).
  • That statement is an unconditional UPDATE runs SET status = ?, outcome = ?, finished_at = ? WHERE id = ? with no status guard, matching today's behaviour. If two terminal callers ever reach the same row, last writer wins for status and outcome together. This is the existing contract for status, extended unchanged.
  • A zero-row update (the run was deleted) is not an error and is not retried.
  • Six of the eight sites discard FinishRun's error (_ = si.svc.FinishRun(...)), unchanged by this card. A discarded failure leaves the row non-terminal with outcome NULL; if it later reaches finished by another path it is bucketed by whatever that path wrote, and if it never does it is not finished and so never reaches the succeeded / skipped / unclassified buckets at all.
  • Consequently the activation guarantee is stated precisely: after activation, unclassified stops growing for runs that reach finished through one of the eight sites above. It is not a claim that no finished row can ever carry a NULL outcome, because this card does not add error handling to paths that discard errors today.

runs.status is unchanged. Nothing that reads status today changes behaviour.

Repository query. RunCountsByDayForAgent (office/repository/sqlite/agent_summary.go) reports five buckets where it reports three today. The bucketing is total over every possible column value:

Bucket Predicate
succeeded status = 'finished' and outcome = 'processed'
skipped status = 'finished' and outcome is any other non-NULL value
unclassified status = 'finished' and outcome IS NULL
failed status IN ('failed','timed_out') (unchanged)
other every remaining status (unchanged)

Response shapes. Two shapes in office/dashboard/agent_summary.go derive from those rows, and both change additively:

  • AgentRunActivityDay (built by padAgentRunActivity) gains skipped and unclassified JSON fields. Its documented invariant becomes total = succeeded + skipped + unclassified + failed + other, so total is unchanged in value for any given day.

  • AgentSuccessRateDay (built by buildSuccessRate) gains one JSON field, unclassified. succeeded now counts processed runs only, while total remains the same full denominator, so the success rate falls to the truth rather than being inflated by non-work.

    Why it gains a field rather than keeping its two. Without one, this shape changes meaning mid-series with no in-band marker, which is the exact discontinuity the NULL-not-0 rule in ## What exists to prevent — and it is worse here than elsewhere, because the consequence is not a subtle miscount. succeeded is today status = 'finished' and becomes status = 'finished' AND outcome = 'processed', while every run finished before activation carries outcome IS NULL. So every pre-activation day reports succeeded = 0 against an unchanged denominator — the Success Rate chart reads 0% across all of history the moment this ships. That is not wrong data (those runs genuinely have no recorded outcome), but presented as two fields it is indistinguishable from a real collapse in agent success, and a reader has no way to tell without going outside the response to kandev_meta.

    With unclassified present the discontinuity is legible in the response itself: a day where unclassified accounts for the whole non-failed, non-other remainder is a pre-activation day, and a day where it is 0 is fully classified. This is the same reasoning, and the same additive treatment, already applied to AgentRunActivityDay; the earlier revision that left this shape at two fields was inconsistent with its own principle rather than deliberately excepting it.

    succeeded + unclassified is deliberately not offered as a compatibility value and no consumer should compute it: it would resurrect exactly the finished-as-success count this slice removes, because unclassified contains pre-activation budget-blocked and idle-skipped runs alongside genuine work.

The Succeeded field in office/repository/sqlite/dashboard.go is sourced from task_sessions.state, not from runs.status, and is not changed by this spec.

No new user-visible copy is introduced: the existing charts keep rendering the fields they already render, and the two new fields are additive on the wire. Consequently no t() string, no locale catalog entry, and no Playwright spec is added by this card. A later card that surfaces skipped or unclassified in the UI owns that copy and that coverage.

RunCountsByDayForAgent is pre-existing SQLite-only code (it uses strftime). This spec does not make it dialect-portable; the Postgres parity requirement under Migration and activation binds the migrations, which is what ADR 0027 governs.

Legacy rows land in unclassified, not succeeded. They are not reinterpreted and not backfilled: the pre-activation series keeps its own bucket, so the discontinuity is visible in the data rather than silent. The activation point tells a consumer when unclassified should stop growing.

"Visible in the data" is meant literally and is satisfied at both grains, which is why AgentSuccessRateDay gains unclassified above. A consumer reading either response shape can separate "this day had no successes" from "this day predates the mechanism" without consulting kandev_meta — the activation instant remains the authoritative answer for when the change happened, but no shape requires it to avoid a false reading.

Scenarios

Classification

  • GIVEN a task whose repository has a non-detached merged pull request, WHEN the ledger is evaluated, THEN the pair records delivery_outcome = pr_merge, delivery_basis = provider_pr_merged, delivery_ref set to that pull request's URL, and evidence_rank = 8.
  • GIVEN a task whose repository is tracked on GitLab and whose merge request has a non-NULL merged_at, WHEN the ledger is evaluated, THEN the pair records pr_merge on the same basis as a GitHub pull request.
  • GIVEN a task whose only Azure DevOps pull request has status = 'Completed', WHEN the ledger is evaluated, THEN the pair records pr_merge — the status match is case-insensitive and whitespace-trimmed.
  • GIVEN a task whose only Azure DevOps pull request has status = 'abandoned', and no snapshots, WHEN the ledger is evaluated, THEN the pair records unknown with delivery_basis = provider_pr_unmerged and evidence_rank = 3.
  • GIVEN a task whose only Azure DevOps pull request has an unrecognised status, and no snapshots, WHEN the ledger is evaluated, THEN the pair records unknown with provider_pr_unmerged — the status is not guessed at and the evaluation does not error.
  • GIVEN a task whose only session ran on a branch equal to the repository's default_branch and whose snapshots carry three distinct head commits, WHEN the ledger is evaluated, THEN the pair records delivery_outcome = direct_commit, delivery_basis = default_branch_commit, and reached_default_at set.
  • GIVEN a task with snapshots showing ahead = 12 on a feature branch, no provider pull request, and no default-branch observation, WHEN the ledger is evaluated, THEN the pair records delivery_outcome = unknown with delivery_basis = branch_commits_unmerged, observed_branch_commits = 12 and evidence_rank = 5.
  • GIVEN that same task, WHEN a later evaluation observes its head commit as an ancestor of the default branch, THEN reached_default_at and reached_default_basis = ancestor_of_default are set and delivery_basis becomes reached_default_unattributed, while delivery_outcome remains unknown.
  • GIVEN a task whose every snapshot shows ahead = 0 and one distinct head commit, WHEN the ledger is evaluated, THEN the pair records no_delivery_observed with basis no_commits_observed and evidence_rank = 4.
  • GIVEN a task whose only snapshot shows ahead = 0 and an empty head_commit, WHEN the ledger is evaluated, THEN the pair records no_delivery_observed — zero distinct non-empty heads satisfies rule 4's "at most one", so no pair falls through the rule set.
  • GIVEN a task whose snapshots carry ahead = NULL and ahead = -1, WHEN the ledger is evaluated, THEN both are read as 0 and the pair is classified as if every snapshot reported ahead = 0.
  • GIVEN a task with a declared repository but no snapshot and no provider pull request, WHEN the ledger is evaluated, THEN the pair records unknown with basis no_observations — not no_delivery_observed.
  • GIVEN a task with no repository at all, WHEN the ledger is evaluated, THEN no ledger row is written for it.
  • GIVEN a repository whose default_branch is empty and a task with snapshots showing ahead = 12, WHEN the ledger is evaluated, THEN the pair records delivery_outcome = unknown, delivery_basis = default_branch_unknown and evidence_rank = 1 — the rule's own basis is superseded — and WHEN default_branch is later populated and the pair is re-evaluated, THEN it is promoted to branch_commits_unmerged at rank 5.
  • GIVEN that same degraded pair, WHEN repositories.default_branch is populated and nothing else changes, THEN the next sweep selects the pair as due — because repositories.updated_at advanced and is one of the named due sources — so the promotion above is reachable without any other input moving.
  • GIVEN a repository whose default_branch is empty and a merged pull request whose base_branch is also empty, WHEN the pair is evaluated, THEN reached_default_at stays NULL — an empty default_branch matches no branch, so the provider-derived observation is suspended along with rule 2 and ancestry.
  • GIVEN a repository whose default_branch is NULL rather than empty, WHEN the pair is evaluated, THEN it takes the same degraded path as an empty string, with delivery_basis = default_branch_unknown and evidence_rank = 1.
  • GIVEN a task with two sessions on one repository, each on its own inherited branch, each reporting a single distinct head_commit that differs between the sessions, WHEN the pair is evaluated, THEN no session has two or more distinct heads of its own, so the pair is not classified direct_commit, no ancestry check is attempted, and reached_default_at stays NULL — distinct heads are never pooled across sessions.
  • GIVEN a pair classified by rule 3, 4, 5 or 6, or degraded by Degraded evaluation, WHEN the row is written, THEN delivery_ref is NULL — including for provider_pr_unmerged, whose provider row URL is evidence of non-delivery and is deliberately not recorded as a delivery ref.

Evidence rank and reclassification

  • GIVEN a pair whose first snapshot shows ahead = 0 with one head commit, classified no_delivery_observed at rank 4, WHEN a later snapshot shows ahead = 12 and the pair is re-evaluated, THEN the row is promoted to unknown / branch_commits_unmerged at rank 5, delivery_ledger_demotions_suppressed_total does not increment, and observed_branch_commits reads 12. Mid-session evaluation must not pin a working pair at no_delivery_observed.
  • GIVEN a pair classified unknown / provider_pr_unmerged at rank 3, WHEN its pull request merges and the pair is re-evaluated, THEN the row reads pr_merge at rank 8.
  • GIVEN a repository whose default_branch is empty and a pair with a merged pull request, stored degraded as pr_merge / default_branch_unknown at rank 1, WHEN the pull request is detached, every snapshot reports ahead = 0, and the pair is re-evaluated while default_branch is still empty, THEN the row reads no_delivery_observed / default_branch_unknown still at rank 1 — the equal-rank write happens — AND delivery_ledger_degraded_outcome_changed_total increments AND delivery_ledger_demotions_suppressed_total does not increment AND updated_at advances. Rank 1 admits more than one outcome, so this is neither a promotion nor a suppressed demotion, and it must not be silent in both counters.
  • GIVEN that same degraded pair, WHEN default_branch is later populated and the pair is re-evaluated, THEN it is promoted to a rank ≥ 2 classification — a degraded row at rank 1 is superseded by any sighted evaluation, whichever outcome it currently holds.
  • GIVEN a pair stored pr_merge / provider_pr_merged at rank 8 whose delivery_ref names pull request A, WHEN a second merged, non-detached pull request B with an earlier merge instant is synced and the pair is re-evaluated, THEN the rank is still 8 and delivery_outcome and delivery_basis are unchanged, but delivery_ref becomes B's URL, updated_at advances and delivery_ledger_rows_written_total increments — equal rank guarantees the same (outcome, basis), never the same ref.
  • GIVEN a pair whose delivery_outcome is unknown and whose workspace_id is W1, WHEN its task is moved to workspace W2 and the pair is re-evaluated, THEN the ledger row's workspace_id reads W2 — it is refreshed on every persisted evaluation and is not gated by the rank guard — AND the pair was selected as due because tasks.updated_at advanced.

Squash-merge, ancestry preconditions and negative evidence

  • GIVEN a merged pull request whose branch head is not an ancestor of the default branch because the merge was squashed, WHEN ancestry is evaluated, THEN the negative result is discarded, no column records false, and the pair's pr_merge classification from rule 1 is unaffected.
  • GIVEN a pair whose snapshots all report the same single head_commit — the inherited base commit, which is trivially an ancestor of the default branch — WHEN the pair is evaluated, THEN no ancestry check is attempted, delivery_ledger_ancestry_skipped_total increments, and reached_default_at remains NULL.
  • GIVEN a pair reporting ahead = 4 on an inherited branch whose head_commit never moved across its snapshots, WHEN the pair is evaluated, THEN the ancestry precondition is still not met and no ancestry check is attempted — ahead > 0 does not satisfy it.
  • GIVEN a pair one of whose sessions carries two distinct head commits among its own snapshots, and whose most recent two snapshots share the greatest created_at, WHEN the ancestry head is selected, THEN it is the lexicographically greatest head_commit of those two, and repeated evaluations select the same one. The session grain is stated in the GIVEN because selection is restricted to the precondition-satisfying session(s); two heads split across two sessions would satisfy no session's precondition and no check would be attempted at all.
  • GIVEN a pair whose session holds two distinct non-empty head commits and whose newest snapshot by created_at carries a whitespace-only head_commit, WHEN the ancestry head is selected, THEN the whitespace-only snapshot is skipped and the newest non-empty head is submitted to the check — an empty value is never sent to git and never counted as an ancestry error.
  • GIVEN a pair classified direct_commit whose newest default-branch snapshot carries an empty head_commit, WHEN delivery_ref is selected, THEN it is the newest non-empty head_commit among the default-branch snapshots of the session(s) that satisfied rule 2, and the same value is offered as reached_default_ref — no empty string reaches either column.
  • GIVEN a pair with two sessions on one repository — session A, on a feature branch, holding two distinct non-empty head commits of its own and therefore satisfying the moving-head precondition, and session B, which merely sat on the repository's default_branch and wrote a single snapshot carrying its inherited head, with a later created_at than any of session A's — WHEN the ancestry head is selected, THEN it is session A's newest non-empty head and not session B's inherited head, even though B's snapshot is the newest on the pair. Selection is restricted to the precondition-satisfying session(s), so a non-authoring session can never supply the commit submitted to git merge-base --is-ancestor.
  • GIVEN that same pair, WHEN the evaluation completes, THEN reached_default_at is not set from session B's inherited head — an inherited head is trivially an ancestor of the default branch, so pair-wide selection would have set the write-once triple with reached_default_basis = ancestor_of_default on a pair whose only authored work is unmerged, and promoted rule 3's basis to reached_default_unattributed. This is the same false positive the Ancestry precondition prevents at the gate, and it is prevented here at selection.
  • GIVEN a pair with two sessions on one repository — session C, which committed on the repository's default_branch and holds two distinct non-empty heads there, and session D, which sat on the same default_branch holding a single inherited head whose snapshot has a later created_at — WHEN delivery_ref is selected for the resulting direct_commit classification, THEN it is session C's newest non-empty head, and the same value is offered as reached_default_ref. Only the session(s) satisfying rule 2's own predicate contribute candidate heads, so session D's inherited head reaches neither column.
  • GIVEN a repository whose local checkout is missing, WHEN ancestry is evaluated, THEN delivery_ledger_ancestry_errors_total increments, reached_default_at stays NULL, delivery_outcome is unchanged, and the rest of the classification is still persisted.
  • GIVEN a repository whose refs/remotes/origin/<default_branch> does not exist but whose local branch <default_branch> does, WHEN ancestry is evaluated, THEN the local branch is used and the check proceeds.
  • GIVEN a provider-cloned repository whose local_path is empty, WHEN ancestry is evaluated, THEN the checkout-resolution port returns an error rather than a path, delivery_ledger_ancestry_errors_total increments, reached_default_at stays NULL, and the rest of the classification is still persisted.
  • GIVEN a repository whose stored local_path canonicalizes to a different location than the one saved, WHEN ancestry is evaluated, THEN the port returns an error and no git command is run against the resolved location — the evaluator inherits that rejection instead of implementing its own path check.
  • GIVEN a pair with a merged pull request whose base branch is the default branch, snapshots committing on the default branch, and a head commit that is an ancestor of the default branch — all three observable in one evaluation — WHEN reached_default_at is first written, THEN reached_default_basis is provider_pr_merged and reached_default_ref is that pull request's URL, because provider merge outranks both other bases; and repeated evaluations of the same inputs select the same basis and ref.
  • GIVEN a pair whose only default-branch evidence is a positive ancestry check, WHEN reached_default_at is written, THEN reached_default_basis is ancestor_of_default and reached_default_ref is the commit submitted to the check.
  • GIVEN a repository whose default_branch is main, and a pair with two merged, non-detached pull requests — request A into release/1.2 with the earlier merge instant, and request B into main — WHEN the pair is evaluated, THEN delivery_ref is A's URL (the pr_merge ref rule does not filter on base branch) while reached_default_ref is B's URL and reached_default_basis is provider_pr_merged (the observation ref rule selects only rows whose base branch equals default_branch). The two refs are drawn from different row sets and must not be the same selection.
  • GIVEN a pair whose only merged pull request is detached and whose base branch is the repository's default_branch, plus snapshots showing ahead > 0, WHEN the pair is evaluated, THEN rule 1 does not match, no provider_pr_merged observation is offered, reached_default_at stays NULL, and the pair classifies unknown / branch_commits_unmerged at rank 5 — a detached row is excluded from the observation exactly as it is from rule 1.

Idempotency, ordering and concurrency

  • GIVEN a ledger row classified pr_merge, WHEN it is evaluated again with unchanged inputs, THEN every classification and observation column is unchanged, updated_at is unchanged, and last_evaluated_at and evaluation_seq advance.
  • GIVEN a pair with two merged pull requests, WHEN delivery_ref is selected, THEN it is the one with the earliest merge instant, and on an exact tie the one with the lower request number.
  • GIVEN a pair with one merged GitHub pull request and one completed Azure DevOps pull request, WHEN delivery_ref is selected, THEN the GitHub row's merged_at and the Azure row's updated_at are compared as merge instants, and on an exact tie at equal request numbers azure_devops sorts before github.
  • GIVEN a pair with two default-branch snapshots sharing the same created_at, WHEN delivery_ref is selected, THEN it is the lexicographically greatest head_commit of the two, and repeated evaluations select the same one.
  • GIVEN a row classified pr_merge at rank 8 with observed_branch_commits = 12, WHEN the pull request is detached and the pair is re-evaluated so the computed evidence ranks lower, THEN the stored outcome, basis, ref and rank are all unchanged, delivery_ledger_demotions_suppressed_total increments, and observed_branch_commits still reads 12.
  • GIVEN a row classified pr_merge whose snapshots are all deleted with their session, WHEN the pair is re-evaluated, THEN observed_branch_commits does not decrease.
  • GIVEN two evaluators writing the same pair concurrently, one computing rank 1 and one computing rank 8, WHEN both statements commit in either order, THEN the row reads pr_merge at rank 8.
  • GIVEN a row whose reached_default_at is NULL and two concurrent evaluations each supplying a different first observation, WHEN both commit, THEN reached_default_at, reached_default_basis and reached_default_ref all come from the same one of the two — the triple is never mixed.
  • GIVEN a row whose reached_default_at is set, WHEN a later evaluation observes the default branch again through a different basis, THEN reached_default_at and reached_default_basis are unchanged.
  • GIVEN a pair with no ledger row and no snapshot and no provider row, WHEN a sweep runs, THEN the pair is selected as due and a row is written with basis no_observations; AND WHEN the next sweep runs with nothing changed, THEN the pair is not selected again.
  • GIVEN an evaluation whose snapshot query returns an error, WHEN the pair is evaluated, THEN no column is written — including last_evaluated_at — delivery_ledger_evaluation_errors_total increments, delivery_ledger_evaluations_total does not increment, and the pair is still due on the next sweep.
  • GIVEN a row whose stored observed_branch_commits is NULL and an incoming value of 7, WHEN the upsert runs, THEN the column reads 7 — a NULL stored value is lower than any incoming value and must not swallow it.
  • GIVEN a pair whose inputs have not changed, WHEN it is re-evaluated, THEN delivery_ledger_evaluations_total increments but delivery_ledger_rows_written_total does not, because no classification or observation column changed and updated_at did not advance.
  • GIVEN a pair with two merged GitLab merge requests carrying the same mr_iid under different project_path values, WHEN delivery_ref is selected, THEN the lower project_path wins and repeated evaluations select the same URL.
  • GIVEN a sweep pass that encounters the same three sessions with empty repository_id on two consecutive passes, WHEN each pass publishes, THEN delivery_ledger_sessions_unattributed_total reads 3 after both — it is a gauge set per pass, not a running total.
  • GIVEN a repository that is soft-deleted, WHEN sweeps run, THEN its pairs' last_evaluated_at stops advancing and their existing rows are retained unchanged.
  • GIVEN a soft-deleted repository with a pair whose reached_default_at is NULL and whose last_evaluated_at is three days old, WHEN sweeps run, THEN the pair is not selected — the freeze is evaluated before the three due conditions and overrides stale refresh.
  • GIVEN a soft-deleted repository with a candidate pair that has no ledger row, WHEN sweeps run, THEN no row is written and the pair is not selected on any pass — the freeze also overrides the unconditionally-due rule.
  • GIVEN that same soft-deleted repository, WHEN deleted_at is cleared, THEN repositories.updated_at advances and the pair is selected as due on the next sweep.
  • GIVEN an archived task with a live repository, WHEN sweeps run, THEN its pairs remain candidates and remain eligible for selection under the same three due conditions as any other pair — archiving a task neither excludes it from candidacy nor freezes it, and no predicate anywhere reads tasks.archived_at.
  • GIVEN that same archived task, WHEN one of its sessions writes a new git snapshot, THEN the pair is selected as due on the next sweep and last_evaluated_at advances — archived pairs are re-evaluated on input movement exactly like unarchived ones.
  • GIVEN that same archived task with reached_default_at already set and no input row moving, WHEN sweeps run, THEN the pair is not selected and last_evaluated_at does not advance — it is due under none of the three conditions, which is correct and is not a freeze. Eligibility is what archiving preserves; continuous advance is not something any pair gets.
  • GIVEN an evaluation that begins reading its inputs at T0 and commits its upsert at T2, and a snapshot for the same pair written at T1 with T0 < T1 < T2, WHEN the upsert commits, THEN the stored last_evaluated_at is T0, so last_evaluated_at < MAX(input.created_at) still holds and the pair is selected as due on the next sweep. Recording T2 would mask the T1 snapshot permanently.
  • GIVEN a pair whose evaluation began at T0 and a concurrent, faster evaluation of the same pair that began at T3 > T0 and committed first, WHEN the slower evaluation commits, THEN last_evaluated_at still reads T3 — the high-water rule means a slow evaluation cannot rewind the column.
  • GIVEN a pair with no snapshots at all, WHEN it is evaluated, THEN observed_branch_commits is NULL and not 0.
  • GIVEN a pair whose stored observed_branch_commits is 12 and whose snapshots have all been deleted, WHEN it is re-evaluated so the incoming value is NULL, THEN the column still reads 12.
  • GIVEN a candidate pair whose non-empty repository_id matches no row in repositories, WHEN a sweep runs, THEN no ledger row is written for it, no upsert is attempted, delivery_ledger_pairs_missing_repository_total counts it for that pass, and delivery_ledger_write_errors_total does not increment — the pair is excluded from candidacy rather than attempted and failed.
  • GIVEN that same pair, WHEN the missing repositories row is later created, THEN the pair becomes a candidate on the next sweep and a ledger row is written.
  • GIVEN a github_task_prs row whose task_id matches no row in tasks — the task was hard-deleted and the provider row was not pruned with it — WHEN a sweep runs, THEN no ledger row is written for that pair, no upsert is attempted, delivery_ledger_pairs_missing_task_total counts it for that pass, and delivery_ledger_write_errors_total does not increment — so the primary migration-failure signal keeps its resting value of 0 on a healthy schema.
  • GIVEN that same orphaned provider row, WHEN two consecutive sweep passes run with nothing changed, THEN delivery_ledger_pairs_missing_task_total reads the same count after both — it is a gauge set per pass, not a running total — and neither pass writes a row or logs delivery_ledger.write_failed.
  • GIVEN a candidate pair rejected for a missing tasks row, WHEN the sweep orders its due pairs, THEN the pair is absent from the ordering entirely rather than sorting on a NULL tasks.created_at, and the exclusion is observable through the gauge rather than being an invisible join drop.
  • GIVEN a ledger table that does not exist because its migration was swallowed, WHEN a sweep pass runs, THEN candidacy is still computed from the input tables, the dueness read fails, every candidate is treated as due, delivery_ledger.dueness_unavailable is logged exactly once for that pass, delivery_ledger_write_errors_total increments once per pair whose upsert failed, delivery_ledger.write_failed is logged exactly once for that pass, delivery_ledger_evaluation_errors_total does not increment, and the pass still reaches its last candidate rather than aborting on the first failure.
  • GIVEN that same missing table and a candidate pair whose repository is soft-deleted, WHEN the pass runs under the dueness fallback, THEN that pair is still not evaluated and no upsert is attempted for it — the fallback widens dueness only, and the freeze is evaluated ahead of it.
  • GIVEN that same missing table and a candidate pair whose task_id matches no tasks row, WHEN the pass runs under the dueness fallback, THEN the pair is excluded by Candidate pairs condition 3 and counted in delivery_ledger_pairs_missing_task_total, and no upsert is attempted for it — so it contributes nothing to delivery_ledger_write_errors_total.
  • GIVEN a healthy ledger table and a transient failure of the dueness read on one pass, WHEN that pass runs, THEN every candidate is evaluated once, every upsert succeeds, delivery_ledger_write_errors_total does not increment, no classification or observation column changes value for a pair whose inputs did not move, and the next pass computes dueness normally.
  • GIVEN that same missing table, WHEN the sweep publishes at the end of the pass, THEN delivery_ledger_last_evaluated_unix reads -1 and delivery_ledger_stall_seconds reads -1, and delivery_ledger.unavailable is logged — not 0/0, which is the healthy-and-empty state and would make a broken ledger indistinguishable from a fresh one.
  • GIVEN a boot at which the task_delivery_ledger migration failed, so telemetry.delivery_ledger.activated_at is absent from kandev_meta, WHEN the backend runs, THEN the sweep still starts and still attempts its upserts — the activation key does not gate the writer, and gating it would silence the very counters that detect the failed migration.
  • GIVEN a pair whose ancestry check errored and whose reached_default_at is therefore NULL, and no input row moves afterwards, WHEN 24 hours pass, THEN the pair is selected as due by the stale-refresh condition and the ancestry check is attempted again.
  • GIVEN a pair whose reached_default_at is NULL, whose session authored two or more distinct non-empty head commits so the ancestry precondition is met, and whose selected head commit is an ancestor of refs/remotes/origin/<default_branch> in the repository's local checkout — a merge that landed long after the task's last session ended, moving no row in any Kandev table — WHEN the next stale refresh evaluates it, THEN reached_default_at is set, reached_default_basis is ancestor_of_default, and reached_default_ref is that commit. The ancestry relation is stated as a precondition rather than assumed: this repository squash-merges by policy, so a merged branch head is normally not an ancestor (see Evidence, finding 2), and a test that omits the relation is asserting nothing.
  • GIVEN that same pair where the merge was squashed, so the selected head is not an ancestor of the default branch, WHEN the next stale refresh evaluates it, THEN the negative result is discarded, reached_default_at stays NULL, and the pair is selected again by stale refresh 24 hours later — the condition retries indefinitely while the observation is missing, and that is the intended cost of not being able to see a squash.
  • GIVEN a pair whose reached_default_at is already set, WHEN 24 hours pass with no input movement, THEN it is not selected by the stale-refresh condition — the condition applies only while the observation is missing.
  • GIVEN a pair evaluated by a sweep pass, WHEN the next sweep runs 5 minutes after that pass finished, THEN at most one pass is ever in flight, so a pass that evaluates that pair advances its evaluation_seq by exactly one, never two, and each per-pass gauge is set exactly once for that pass. This says nothing about whether the pair is evaluated at all on a given pass — a pair that is not due is not evaluated and its evaluation_seq does not move, per the dueness scenarios above. The guarantee is against double-counting within a pass, not a promise of one increment per pass.

Migration and activation

  • GIVEN a database created before this feature, WHEN the backend boots, THEN task_delivery_ledger exists, runs.outcome exists and is NULL on every pre-existing row, and no pre-existing row's values change.
  • GIVEN the backend boots twice against the same database, WHEN migrations replay, THEN they apply cleanly the second time and telemetry.delivery_ledger.activated_at retains the value written on the first boot.
  • GIVEN a Postgres deployment, WHEN the same migrations run fresh and then replay, THEN both succeed and duplicate-object errors are classified through internal/db.
  • GIVEN a boot in which the task_delivery_ledger migration fails and the runner swallows the error at WARN, WHEN the boot completes, THEN telemetry.delivery_ledger.activated_at is absent from kandev_meta, because the schema probe found no table; AND WHEN a later boot applies the migration successfully, THEN the key is written with that boot's instant.
  • GIVEN a boot in which the runs.outcome migration fails, WHEN the boot completes, THEN telemetry.run_outcome.activated_at is absent for the same reason, so no consumer reads the mechanism as live while the column is missing.
  • GIVEN an activated database, WHEN a database reset runs, THEN telemetry.delivery_ledger.activated_at and telemetry.run_outcome.activated_at are absent from kandev_meta afterwards, and WHEN the backend next boots, THEN both are rewritten with the new instant.
  • GIVEN an activated database, WHEN a reset deletes both activation keys and then fails partway through dropping user tables, THEN task_delivery_ledger rows may remain but both keys are absent — so no consumer reads the mechanism as live over data it cannot trust — and WHEN the backend next boots and the schema probe succeeds, THEN the keys are rewritten with that boot's instant.
  • GIVEN a reset in which the activation-key deletion itself fails, WHEN the reset runs, THEN it fails and reports the failure, and no user table is dropped — the keys are never left pointing at data that a later step removed.
  • GIVEN a task created before telemetry.delivery_ledger.activated_at that has no ledger row, WHEN the database is read, THEN the two facts are separately observable — the activation instant from kandev_meta and the absence of a row from task_delivery_ledger — so "not observed by this mechanism" is distinguishable from no_delivery_observed without inspecting any consumer.

Office run outcome

  • GIVEN a budget block, WHEN it finishes, THEN runs.outcome is 'budget_blocked' if limit-reached else 'budget_unmeasurable'.
  • GIVEN a run skipped because the agent is not active, WHEN it finishes, THEN runs.outcome = 'agent_inactive' — even though that path writes no office_activity_log row.
  • GIVEN a run held by an active task-tree hold, WHEN it finishes, THEN its outcome is task_tree_held.
  • GIVEN a run whose task checkout fails transiently, WHEN the scheduler handles it, THEN it is retried or escalated after the retry budget and is not finished as completed work.
  • GIVEN a run whose task is already checked out, WHEN the scheduler handles it, THEN it is requeued for retry and is not finished as completed work.
  • GIVEN a task-bearing run whose agent was launched by the task starter, WHEN the agent-completed subscriber finishes it, THEN runs.outcome = 'processed' and it is not left NULL after activation.
  • GIVEN a taskless run, WHEN the scheduler cannot launch it, THEN it is marked failed with a diagnostic error and does not report completed work.
  • GIVEN a task-bearing run in a deployment with no task starter configured, WHEN the scheduler handles it, THEN it is marked failed with a diagnostic error, its checkout is released, and the agent is not launched.
  • GIVEN two terminal callers reaching the same run row, WHEN both write, THEN the row's status and outcome both come from the later write — they are set in one statement and can never disagree.
  • GIVEN a run that fails, WHEN FailRun transitions it through the shared transitionRunTerminal statement, THEN runs.status = 'failed' and runs.outcome IS NULL — the failed path writes NULL in the same statement, and no value from the seven-value vocabulary is invented for it.
  • GIVEN a day containing one failed run written that way, WHEN RunCountsByDayForAgent reports that day, THEN it counts in failed and in neither unclassified nor skipped — the NULL outcome is never read, because the three outcome-derived buckets are all conditioned on status = 'finished'.
  • GIVEN a run whose FinishRun call fails and whose error the call site discards, WHEN the run is later inspected, THEN it is not finished, so it counts in neither succeeded nor skipped nor unclassified.
  • GIVEN a run transitioned to a terminal status through office/scheduler.SchedulerService.FailRun, which reaches the runs repository's FinishRun without passing through transitionRunTerminal, WHEN the row is inspected, THEN runs.status = 'failed' and runs.outcome IS NULL, and the row counts in failed and in neither unclassified nor skipped.
  • GIVEN a run transitioned through office/scheduler.SchedulerService.FinishRun, WHEN the row is inspected, THEN runs.status = 'finished' and runs.outcome IS NULL, so it counts in unclassified and not in succeeded — a terminal path with no established semantic label is never counted as a success.
  • GIVEN a day containing one processed run, one budget-blocked run, one failed run and one pre-activation finished run with outcome IS NULL, WHEN RunCountsByDayForAgent reports that day, THEN it returns succeeded = 1, skipped = 1, unclassified = 1, failed = 1 and other = 0.
  • GIVEN a finished run whose outcome holds a value outside the seven named ones, WHEN that day is reported, THEN it counts in skipped — the bucketing is total and no query errors.
  • GIVEN that same day, WHEN the agent dashboard renders it, THEN AgentRunActivityDay.total equals succeeded + skipped + unclassified + failed + other, and AgentSuccessRateDay reports succeeded = 1 and unclassified = 1 against the same total.
  • GIVEN a day made up entirely of pre-activation finished runs with outcome IS NULL — three of them, and nothing else — WHEN RunCountsByDayForAgent reports that day, THEN succeeded = 0, skipped = 0, unclassified = 3, failed = 0, other = 0; AND WHEN the dashboard renders it, THEN AgentSuccessRateDay reports succeeded = 0, unclassified = 3 and total = 3, so a reader can tell from the response alone that the day predates the mechanism rather than recording three failures to succeed.
  • GIVEN a day made up entirely of post-activation runs, WHEN the dashboard renders it, THEN AgentSuccessRateDay.unclassified is 0 — the field is the marker for the pre-activation series and reads zero once every terminal run carries an outcome.

Writer health

  • GIVEN the ledger writer has not evaluated any pair for longer than its 15-minute stall threshold while task sessions continue to complete, WHEN the sweep completes a pass, THEN delivery_ledger_stall_seconds exceeds 900 and a WARN log with the message delivery_ledger.stalled is emitted once for that pass.
  • GIVEN the backend restarts, WHEN the first sweep after the restart completes, THEN delivery_ledger_last_evaluated_unix reports the true MAX(last_evaluated_at) from the database even though every counter has reset to zero.
  • GIVEN an empty ledger table, WHEN the sweep publishes, THEN delivery_ledger_last_evaluated_unix is 0 and delivery_ledger_stall_seconds is 0 — an empty table is not reported as an infinite stall.
  • GIVEN a ledger holding evaluated rows and a database in which no session survives the comparand filters — every session has an empty repository_id, or belongs to a soft-deleted repository, or there are no sessions at all — WHEN the sweep publishes, THEN delivery_ledger_last_evaluated_unix reports the true MAX(last_evaluated_at) and delivery_ledger_stall_seconds is 0, and no delivery_ledger.stalled WARN is emitted — an empty comparand means there is no work to be behind on, not that the writer is stalled.
  • GIVEN a ledger holding evaluated rows and a comparand query that errors while the ledger query succeeds, WHEN the sweep publishes, THEN delivery_ledger_last_evaluated_unix reports the true MAX(last_evaluated_at), delivery_ledger_stall_seconds reads -1, and delivery_ledger.comparand_unavailable is logged once for that pass — the stall figure is unknown rather than zero, so a repeatedly failing comparand read can never report a healthy writer.
  • GIVEN a healthy writer that has evaluated every due pair, and a session with an empty repository_id that updated one hour ago, WHEN the sweep publishes, THEN delivery_ledger_stall_seconds is 0 and no delivery_ledger.stalled WARN is emitted — an unattributed session contributes to no pair and must not drive the comparand.
  • GIVEN a healthy writer and a session belonging to a soft-deleted repository that updated one hour ago, WHEN the sweep publishes, THEN delivery_ledger_stall_seconds is 0 — that pair's evaluation is frozen by design.
  • GIVEN a writer that has stopped, and an archived task whose session updated twenty minutes ago, WHEN the sweep publishes, THEN delivery_ledger_stall_seconds exceeds 900 and delivery_ledger.stalled is emitted — archived tasks are still evaluated, so their sessions still count toward the comparand and a stall on them is a real stall.

Out of scope

Each exclusion below is a contract, not an omission.

  • The extract itself. The downstream point-in-time extract that reads this table is external to this repository; no extract code exists here and none is added. This spec's obligation is to make the ledger readable without ambiguity — the activation instant in kandev_meta, NULL rather than 0 for legacy rows, and evaluation_seq — and every scenario above is stated as an observable property of the database, never of a consumer.

  • A structured stop reason for a task or session. task_sessions carries free-text error_message ("task archived", "task tree archived", raw rate-limit JSON) and a state enum, and nothing else. This is a different grain (session, not run), a different table, and its enum cannot be derived without enumerating every writer of error_message and every state transition. It gets its own card. The runs.outcome contract in this spec does not cover it and must not be extended to task_sessions.

  • Consuming the GitHub push webhook for merge inference. The push_webhook_default basis is defined in the vocabulary so a later card can populate it without a schema change, but no subscriber is added here. The webhook fires only for GitHub App installations; 10 of the 12 repositories in the reference store have no provider at all, so it can never be the primary mechanism.

  • Reconstructing history. No ledger column is ever backdated or inferred from evidence the mechanism did not itself observe. first_classified_at and reached_default_at are the instants this mechanism made the observation, never the instant the underlying work happened, so a pair whose branch merged months before activation records the observation instant and not the merge. There is no import, no reconstruction from office_activity_log, and no derivation from a provider's history API.

    This is not a claim that old pairs go unevaluated. Every pre-existing (task, repository) pair is a candidate, has no ledger row, and is therefore due on the first sweep after activation and classified from whatever evidence is still present — see Sweep selection predicate. Those two statements were previously in tension in this document; the distinction that resolves them is reconstruction versus evaluation. Classifying an old pair from evidence visible today is ordinary evaluation. Asserting when that pair delivered, from evidence the ledger never saw, is reconstruction, and only reconstruction is excluded. The activation instant in kandev_meta is what lets a consumer tell the two apart: every row is timestamped at or after it.

  • Making RunCountsByDayForAgent dialect-portable. It is pre-existing SQLite-only code; this card reshapes its buckets and nothing else.

  • Retiring the dormant office/scheduler.SchedulerService.FinishRun / .FailRun pair. They call the runs repository's FinishRun directly, bypassing transitionRunTerminal and therefore also bypassing the OfficeRunProcessed publish that the service-layer path performs. This card threads outcome through them (both pass NULL, see Office run outcome) because the shared signature change forces the compiler to reach them, and stops there. Deciding whether a second terminal-transition path should exist at all — and whether the missing bus publish is a defect — is a behavioural question about run lifecycle, not about telemetry, and it gets its own card.

  • The second status = 'finished'-as-success consumer. apps/backend/internal/office/summary/inputs.go counts r.status = 'finished' as completed when it assembles continuation summaries for an agent. It has exactly the defect this card fixes in RunCountsByDayForAgent — a budget-blocked or idle-skipped run reads as completed work — and it is deliberately not changed here. It is a different consumer at a different grain, feeding agent prompt context rather than a dashboard, and changing what an agent is told about its own history is a behavioural change that deserves its own card and its own review. It is named here rather than left silent so that after this card ships, nobody concludes from ## Why that every finished-as-success reader has been corrected. It gets its own card.

  • Distinguishing a research card from a chore. The ledger separates delivery from non-delivery. It does not classify intent, and no_delivery_observed is not a judgement about whether the card should have delivered.

  • Attributing delivery to a session, a turn, or a workflow step. The ledger's grain is (task, repository). A session's snapshots attribute to that session's single task_sessions.repository_id; a session spanning more than one repository is not modelled.

  • Deduplicating delivery credit when one pull request serves several tasks. Both tasks read pr_merge.

  • Recording a closed pull request's disposition (superseded, duplicate, withdrawn). That is a separate card against the provider tables.

  • Any user-visible surface. No board column, badge, filter, dashboard tile, or settings control is added, and no new copy is rendered. If a later card surfaces the ledger or the new run-outcome buckets in the web UI, that copy goes through t() and that card owns the E2E coverage.

  • Changing runs.status semantics, or changing what any existing consumer of runs.status reports.

  • Populating task_session_commits, pre_commit_snapshot_id, or post_commit_snapshot_id. Those are separate cards and this spec does not depend on them.