--- status: draft created: 2026-08-12 updated: 2026-08-13 revision: 5 owner: nova28 decisions: - ../../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 six terminal call sites, **four** of which did no work, and `agent_summary.go` counts every `finished` row as a success. Only two of the six — the agent-completed subscribers — follow an agent actually running. The four 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, six 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 | idle_skipped | agent_inactive | task_tree_held`. `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 five 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/`, falling back to the local 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:247` — idle skip | `idle_skipped` | | `office/service/scheduler_integration.go:517` — task-tree hold | `task_tree_held` | | `office/service/scheduler_integration.go:829` — pre-execution budget block | `budget_blocked` | | `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 and failure path. A contention result requeues the run and retries it. Neither path 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`, `func (r *Repository) FinishRun(ctx context.Context, id, status string) error` — which is the only implementation of that method in the repository. Per **Citation convention** the semantic label is authoritative: the statement to extend is *the one inside the runs repository's `FinishRun`*, not a statement inside `transitionRunTerminal`, which does not exist. This distinction is not pedantic, because the 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 five-value vocabulary would ever be read, and inventing one would assert a classification nothing consumes. This also makes the sentence under `### runs.outcome` exact: the five-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 six-site table. Both **pass `NULL`**, and neither is added to the call-site table. The reasoning, so a builder does not have to guess and does not "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 six 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. - Four of the six sites discard `FinishRun`'s error (`_ = si.svc.FinishRun(...)`). That is 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 six 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/` does not exist but whose local 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/` 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 run blocked by the pre-execution budget check, **WHEN** it finishes, **THEN** `runs.status = 'finished'` and `runs.outcome = 'budget_blocked'`. - **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 five-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 five 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.