Effort calibration — landed evidence

One cumulative record, updated as each node lands. The first node produced a qualified result: the promotion path now records where its completion time came from, and that recording is what exposed the fact that this backend supplies no timestamp to read.

Declared measures and what each has produced

MeasureEvidenceVerdict
Migration is losslessLetters map to 1/2/4/8 hours with uncalibrated plans marked, and a plan with neither field gains nothing — § hours unitpass
Loops are separable2.0× agent moves only its factor; 2.0× plan moves only its estimate — § calibration loopspass
Scope change excludedA 200 h scope-changed outlier and a 50 h proxy-timed one are both withheld — § calibration loopspass
Cold start terminatesEight noisy observations converge to 3.009 h and factor 2.003 — § calibration loopspass
Horizon drives splitting4.0 ÷ 1.4 = 2.857 h refused above a 2.5 h horizon with a 3.5 h split target; 1.5 ÷ 0.9 = 1.667 h dispatched — § competence horizonpass
Cache is rebuildableDelete and rebuild reproduced byte-equivalent figures; nothing is authored — § capabilities cachepass
Lines are unrankableAn AST guard confines consumption to one descriptive helper and its single call site — § capabilities cachepass
Unit stated everywherePlan read, sprint capacity, plans list and authoring guidance all name worker-hours; unit grep found 0 bare figures — § unit surfacedpass
Prediction beats the lettersExact tie at MAE 2.625391 h over 64 usable records — a recorded negative result — § predictionnegative
Suite greenFailed at 1,826/5, repaired, then 1,837 passed / 0 failed§ suite regressionpass

The integrated branch is green, and promoting the check exposed a second defect

A read-only verification node ran uv run pytest -q once from a worktree of the integrated primary branch: 1,731 passed, 0 failed, exit 0, 276 s, no failing test names. That discharges the suite measure for every node landed to this point.

The node produced no commit, which is how it exposed the other half of the promotion-boundary defect — and the real mechanism is broader than --commit none being accepted as a revision. Promoting with no commit argument at all still populated changed_lines, and what it stored was git's own error text:

changed_lines = {"detail": "fatal: /…/scratchpad/logs/integration-suite.log: '/…/scratchpad/logs/integration-suite…"}

So the field that is supposed to carry a diff statistic carries a truncated diagnostic string, and no argument was needed to trigger it. The diff was attempted against this node's declared write path, which is an absolute path outside the repository — legitimate for a log, meaningless to git. Two distinct repairs follow: a failed diff must surface as an explicit absence rather than as captured stderr, and a run that legitimately produced no commit must be representable as such rather than inferred. A record carrying an error string where a number belongs is worse than an empty one, because every later reader has to know it is not data. Repaired below.

An unavailable measurement is an absence, not a diagnostic string

Commit 0a83bdereckon/crew.py, reckon/ledger.py, tests/test_crew.py, tests/test_ledger.py; 97 insertions, 24 deletions across 4 files. Gate passed: 100 passed, 0 failed, 6.7 s, 3 new tests. Scope was widened mid-flight, so this run is excluded from calibration.

CaseResult
No commit argumentcommits is an empty list and changed_lines is null; no diff is run and no git output is captured
Unresolvable commit valueRefused before promotion, naming the value, and the live pointer stays recoverable rather than being consumed by the failed attempt
Write path outside the repositorychanged_lines is null; a diagnostic string cannot reach the field
Commit-backed diffStill records added, removed and file counts

Two details are worth keeping. The refusal leaves the pointer recoverable, so a mistyped revision costs a retry rather than a lost run. And promoting this very node showed the scoped diff measuring the declared fence rather than the widened one — 77 insertions across 3 files recorded against a commit of 97 across 4, because the fourth file was granted after dispatch. That is correct behaviour and it is exactly why a scope-changed run is withheld from calibration: its diff measures the fence it was given, not the work it did.

Promotion folds the run's terminal state, and names its source

Commit 69a3a21reckon/crew.py, reckon/ledger.py, tests/test_ledger.py; 93 insertions, 5 deletions across 3 files. Gate passed on its declared measure.

Completion no longer depends on someone having called observe at the right moment. complete folds the run's terminal event directly, so token usage reaches the ledger from the stream even when no observation ever happened, and completed_at_source joins RECORD_FIELDS so every new record states whether its finish time came from an event or from the wall-clock at promotion.

MeasureResult
Terminal event honouredSynthetic stream ending at 2027-01-02T03:04:05Z promoted with that exact completed_at, source terminal_event, and 29,253 input / 5 output tokens recovered with no prior observe
Fallback is explicitUntimestamped stream fell back to promotion time and recorded completed_at_source=promotion_time
Field is universalcompleted_at_source asserted present on every record build_record emits
Scoped suitetests/test_crew.py tests/test_ledger.py — 97 passed, 0 failed, 6.4 s; 3 new tests; ruff clean

Effort in worker-hours, with the letters mapping losslessly onto it

Commit 2325240reckon/_plan_html.py, reckon/_schema.py, reckon/_store.py, docs/_shared/plan.schema.json, tests/test_schema.py, tests/test_capability.py; 198 insertions, 5 deletions across 6 files. Gate passed: 1,098 passed, 0 failed, 24.3 s, 7 new test functions over 10 collected cases, ruff clean.

plan-effort-hours validates as a positive number at quarter-hour granularity — published in the regenerated schema as multipleOf 0.25 — and effort_calibrated joins it as read-only provenance so a resolved figure always states whether it was authored or inferred. Legacy effort is marked deprecated rather than removed, which is what makes the migration cost nothing.

CaseResult
Explicit hours3.25 h round-trips; 2.75 h through the state-set operation is accepted and marked calibrated
Hours and a letter together5.25 h with XL resolves 5.25, effort_calibrated=true, and reports the letter redundant
Letter onlyS→1.0, M→2.0, L→4.0, XL→8.0, effort_calibrated=false, reported uncalibrated
Neither fieldGains none of effort, effort_hours, effort_calibrated or compatibility_warnings
Writes do not fabricateA normal write preserves uncalibrated status instead of authoring derived hours into the document

The last row is the property that keeps the unit meaningful: an inferred figure is resolved on read and never written back, so the mapping stays a compatibility read rather than becoming a silent re-estimate of every historical plan.

Paths report durations, not scores

Commit 07fb21breckon/roadmap.py, tests/test_roadmap.py; 188 insertions, 21 deletions across 2 files. Gate passed: 29 passed, 0 failed, 4 new tests.

The roadmap response now names its unit — unit=worker-hours — and carries per-plan effort_hours and remaining_effort_hours with length_hours on critical and open paths. Naming the unit in the payload is the point: an unlabelled number invites the reader to assume the old doubling scale.

CaseResult
Explicit hours win5.0 h overrides a legacy XL and yields 3.75 h remaining at 25% implementation
Letter-onlyL maps to 4.0 h and the plan is reported uncalibrated
Path totalsEqual the sum of per-plan remaining_effort_hours within a tested 0.25 h tolerance
Uncalibrated visibleuncalibrated_count and uncalibrated_plans reported beside the totals
Completed plansZero-remaining plans excluded from the actionable uncalibrated count

The last row keeps the count honest: a finished plan that was never estimated in hours is not work anyone can act on, so counting it would inflate the figure that is supposed to say how much re-estimating is left.

Two loops that do not contaminate each other

Commit b93cfcdreckon/calibration.py, tests/test_calibration.py; 364 insertions across 2 new files. Gate passed: 9 passed, 0 failed, 9 new tests, ruff clean.

Separability is asserted in both directions rather than assumed, which is what the plan warned would quietly break if built naively:

CaseResult
Slow agent, sound estimatesAn agent at exactly 2.0× declared hours across three plans resolves to factor 2.0 while all three plan estimates stay identical
Underestimated plan, baseline agentFour 4.0 h runs against a 2.0 h plan resolve the plan to 4.0 h while the factor stays exactly CalibrationFigure(value=1.0, confidence=1.0, samples=20)
Scope-changed runA 200.0 h outlier is excluded from both loops, leaving the usable 2.0 figure and reporting one exclusion in its category
Proxy-timed runA 50.0 h promotion_time outlier is excluded the same way
Cold startFrom empty history, eight noisy observations converge to 3.009 h (confidence 0.764, 8 samples) and factor 2.003 (confidence 0.775, 8 samples) — both within 0.05 of their synthetic targets, without oscillating
Missing dataMissing counterparts and invalid durations are excluded explicitly rather than guessed

The two outlier rows are what make the earlier repairs pay off. A 200-hour scope-changed run and a 50-hour proxy-timed run are exactly the shapes this sprint's own ledger produced, and both are now withheld by construction rather than by anyone remembering to filter them. Confidence travels with each figure, so an agent with eight samples cannot present its number with the authority of one with eighty.

A disposable capabilities cache, and what it says the ledger is worth

Commit 55e9148reckon/capabilities.py, tests/test_capabilities.py; 501 insertions across 2 new files. Gate passed: 10 passed, 0 failed, 10 new tests, ruff clean. Cache path <RECKON_HOME>/cache/capabilities.json.

MeasureResult
RebuildableDeleting and rebuilding reproduced byte-equivalent figures; direct derivation, rebuild return and the JSON cache were exactly equal
Nothing authoredEach ledger version and a deterministic source digest are recorded, so every value traces to a committed record
Size-conditioned successsuccess_by_estimated_hours is cumulative over configurable hour bins; competence_horizon_hours is the largest measured size meeting the threshold
Distribution, not just a meanSpeed carries sorted empirical values with mean, median, min, max, p10 and p90
Lines unrankablechanged_lines is copied only into observation metadata, and an AST guard confines its consumption to one descriptive helper plus its single call site

The AST guard is the right shape for that last measure. The plan required the bar against ranking to be enforced by test rather than convention, and a test that inspects the syntax tree fails when a future edit feeds the field into a sort or comparison — which a test asserting only today's outputs would not catch.

The honest figure: 59 runs, 0 usable configurations

Run against this project's live ledger, the cache read 59 committed runs and produced 0 configurations, having excluded 3 scope-changed and 56 untrustworthy-completion records. The exclusion rules are working exactly as specified; what they reveal is that the historical ledger is essentially empty as calibration input. The breakdown:

Completion sourceRunsUsableWhy
absent38noWritten before the field existed
provided12noA hand-supplied stamp is not independently verifiable
promotion_time9noThe known-inflated fallback
stream_mtime / terminal_event0None yet at the time of the probe

The 12 provided records are an own goal worth recording. They are this sprint's own promotions, hand-corrected from the stream's last-write time while the automatic path was still being built — accurate, but classified as unverifiable and therefore excluded. The correction is to stop supplying the stamp and let the ranked-source code derive it: promoting the capabilities node itself with no explicit stamp produced completed_at_source=stream_mtime and 340 s, the first trustworthy record in this ledger. Every later promotion follows that path.

This bears directly on the plan's final gate. A prediction comparison between hours and the legacy letters needs usable runs, and at the time of this probe there were none — so that gate cannot be discharged on historical data and must wait for records the automatic path produces. A negative result on sample size is a legitimate outcome; fitting the comparison to the data that exists would not be.

A measurement decides how small to split, and its absence decides nothing

Commit 98b3a03reckon/crew.py, tests/test_crew.py; 283 insertions, 8 deletions across 2 files. Gate passed: 76 passed, 0 failed, 4 new tests, ruff clean and formatted.

Dispatch reads the node's neutral worker-hours from the canonical plan — explicit or legacy-mapped — divides by the measured mean speed factor for the exact agent configuration recorded on run records, and compares the result against that configuration's measured horizon. All four paths were exercised through real dispatch calls:

CaseArithmeticVerdict
Above the horizon4.0 ÷ 1.4 = 2.857 h vs 2.5 hRefused before worktree creation, with a 3.5 worker-hour split target
Below the horizon1.5 ÷ 0.9 = 1.667 h vs 2.5 hDispatched and persisted with allowed=true
Empty capabilities cacheno figureallowed=true, reason=no-measured-horizon
Null horizon for the configurationno figureallowed=true, reason=no-measured-horizon

The last two rows are the ones that matter most right now, and they were specified deliberately rather than discovered: this project's capabilities cache currently yields zero configurations, so a horizon check that treated a missing measurement as a small one would refuse every node in the fleet. Absence is explicitly non-blocking, in the same spirit as an unknown budget signal never holding a wave.

Two ordering details worth keeping: the refusal raises before the budget check and before any worktree exists, so a refused node costs nothing and leaves nothing to unwind; and the allowance verdict is recorded on successful runs, so a later reader can see the check ran rather than inferring it from the absence of a refusal.

Every surface names its unit

Commit 590102ereckon/mcp_views.py, reckon/project_state.py, docs/ui/home.jsx, skills/reckon-create/SKILL.md, tests/test_mcp_views.py; 195 insertions, 9 deletions across 5 files. Gate passed: 87 passed, 0 failed, 5 new tests, ruff clean, and the required unit grep returned MATCH_COUNT=0.

SurfaceWhat it now reports
Typed plan readestimated_hours, spent_hours, remaining_hours with unit=worker-hours
Sprint summary and discovery cardstotal_hours with unit=worker-hours, summed from items hydrated out of live plan HTML
Plans listExplicit estimates rendered as worker-hours
Authoring guidancereckon-create instructs hours at quarter-hour granularity rather than a letter

Spend is derived — estimate multiplied by implementation fraction, with remaining as the difference — and the node hit the consequence of that head-on: adding derived capacity to sprint state broke durable migration parity, 10 failures and 38 errors, because a computed figure had entered a store that compares persisted bytes. The resolution was to exclude derived capacity and hydrated effort from durable parity rather than to persist them, which is the same principle the capabilities cache follows and the one derived-gate-state exists to enforce. Final lane 87 passed, 0 failed.

One honest limitation recorded by the worker: the fleet search reads explicit hours from each plan endpoint because discovery inventory still carries only the compatibility letter. The figure is correct; the path to it is less direct than it will be once inventory carries hours.

The closing gate — hours did not beat the letters, and could not have

No commit; analysis only, at repository revision 8023dc6. The full calibration suite stayed green alongside it: 9 passed, 0 failed.

Each prediction was reconstructed from plan state as committed at the run's own base_sha rather than from today's plan, so no prediction benefits from hindsight. Hours-based remaining effort used historical effort_hours × (1 − historical impl); the comparator used the historical S/M/L/XL mapping 1/2/4/8 against the same remaining fraction, matching the pre-migration implementation.

QuantityValue
Ledger records examined75
Calibration-usable64
Excluded — scope-changed4
Excluded — untrustworthy completion1
Excluded — no agent configuration5
Excluded — zero duration1
Hours-based MAE2.625391 worker-hours
Legacy-letter MAE2.625391 worker-hours
LowerNeither — exact tie

The tie is not a coincidence and not a measurement of the hypothesis. All 64 usable snapshots resolved their hours through the legacy letter fallback, because not one plan in the sample carried an independently authored plan-effort-hours — the unit landed during this same sprint, so every historical plan still resolves 1/2/4/8. Two numerically identical predictors cannot separate, and the identity is what makes the result informative in a different direction: it is a clean demonstration that the migration is lossless, since the new arithmetic reproduces the old to seven digits over 64 records.

So the plan's closing question is unanswered by construction, and that is the finding rather than a shortfall to tune away. The plan's own constraint anticipated exactly this and forbids the alternative: the weights were not adjusted, the comparator was not softened, and no subset was selected to manufacture a difference. Testing whether authored hours predict better requires plans that carry authored hours, which is a trigger for a later re-measurement, not work that can be finished now.

Worth noting what the exclusions cost. Eleven of 75 records were unusable, and 5 of those simply lacked an agent configuration — a gap in older records rather than a defect in the current path. The exclusions are reported rather than quietly dropped, which is the whole point of counting them.

Gate FAILED — five integration regressions no single node's gate could see

The full suite on integrated revision 279b6f1: 1,826 passed, 5 failed, exit 1, 58.7 s. Every one of the fifteen nodes landed so far passed its own scoped gate, and the suite still broke — which is the entire argument for keeping a separate integration gate rather than trusting the sum of scoped ones. Downstream work is held per gates.on_fail, not worked around.

Failing testCause
test_budget.py::test_promotion_preserves_backend_when_the_agent_block_is_absent The fixture promotes the placeholder revision deadbee, which the new commit boundary correctly refuses. The test asserts the old contract.
test_fleet_migration.py::test_fleet_run_snapshots_every_mount_and_applies_only_selected_project Observed a rolled-back state where it expected verified.
test_fleet_migration.py::test_install_failure_restores_snapshot_exactly Verification rejected one legacy capability before the deliberately injected installation failure, so the test never reached the failure it was written to exercise.
test_fleet_migration.py::test_cli_and_commit_record_keep_ledger_machine_readable A CLI result lacked the after key.
test_provenance.py::test_non_plan_canonical_dump_omits_neutral_plan_defaults A neutral milestone value of an em-dash reached plan-only validation on a non-plan canonical dump — so the tightened milestone constraint is firing where it should not apply.

The five are not one fault. The first is a stale fixture asserting a contract this sprint deliberately changed, and its repair is to update the fixture, not to loosen the boundary. The last is a genuine defect in the tightening itself: a constraint scoped to plans is being applied to a non-plan resource, which is a scoping bug rather than a validation that is too strict. The three fleet-migration failures share a neighbourhood — snapshot, verification and CLI reporting around the same migration path — and the likeliest common cause is a schema addition reaching a verification step that enumerates known fields.

The verification node modified no repository file and did not rerun the suite, which is what a read-only gate should do: report the failure precisely enough to scope its repair, and change nothing.

Repaired, and re-gated green

Three corrective nodes, one per root cause. The full suite on integrated 99583a4: 1,837 passed, 0 failed, exit 0, 54.3 s, no failing test names.

RepairCommitWhat it changed
Promotion fixtureb8a9934 The fixture resolves its repository HEAD to a full SHA and promotes that. The strict boundary is untouched — the test was updated to assert the current contract, not the contract relaxed to suit the test.
Non-plan validation scope21288f8 The legacy milestone placeholder joins the neutral values stripped from non-plan canonical writes. A plan still refuses an em-dash and a non-plan still refuses a meaningful milestone, both proven by existing tests staying green, plus one new case pinning the boundary.
Fleet verification5ef8876 Counts persisted tier fields directly from repository_inventory instead of reading audit warnings.

The fleet root cause is the most instructive of the three. The field reaching verification was effort_calibrated=false, derived from a fixture's legacy plan-effort S. Its uncalibrated-effort compatibility warning was emitted under the broad legacy-capability-tier code, so verification mistook an effort diagnostic for a persisted tier and rolled the migration back. The defect was not the new field; it was that a verification step inferred structure by pattern-matching a diagnostic string. Counting the fields directly removes the coupling rather than teaching the matcher about one more field it should ignore.

Two process notes from this repair, recorded because both are coordinator errors rather than worker ones. A manifest reported commit 21288f8fdd927ed6e169968cb8f5ef70bc333411, which does not exist — the real commit is 21288f82216afce3bb5ebaa1d47fbf16c0446337, agreeing only on the abbreviated prefix. git merge refused it, which is the only reason a nonexistent SHA did not reach this record and the plan's commit list. And the fleet node's gate command named a test file a concurrent node owned, so its lane could not go green regardless of its own work; its three target tests passed, and the red was a gate-specification error.

A repair pass, and what it says the ledger can become

Commit 73f43f4reckon/ledger.py, reckon/cli.py, tests/test_ledger.py; 390 insertions, 6 deletions across 3 files. Gate passed: 45 passed, 0 failed, 6 new tests, ruff clean and formatted.

reckon crew repair-completion re-derives completion for existing records from their surviving stream files, ranking the newest aware event timestamp above the newest mtime across stream.jsonl and every resume-*.jsonl, and re-deriving worker_seconds with it. It reports without writing by default and persists only with --write; a second write pass performs no write and leaves the ledger version unchanged, so the operation is idempotent rather than merely repeatable.

The read-only preview over this project's live ledger — 65 records at version 77 — is the figure that matters:

OutcomeRecords
Repairable58
Already correct6
Unusable — no surviving stream1
Source that would supply the valueRecords
stream_mtime62
terminal_event2
none surviving1

Two observations. Two records resolve from a real event timestamp, so the terminal-event path is not dead after all — some runs in this ledger do carry one, which means the ranking is doing real work rather than always falling through to the proxy. And the single unusable record stays byte-identical and is reported calibration_usable=false rather than being rewritten with a guess, which is the distinction between repairing a measurement and inventing one.

Against the earlier finding that 59 records yielded zero usable configurations, this is the route out: a write pass would move roughly 58 records into the trustworthy sources that effort reporting accepts.

The write pass, and the measurements it unlocked

Commit 540ccde. The authorised pass repaired 58 records, left 9 already correct, reported 1 unusable, and advanced the ledger once from version 80 to 81. A second identical invocation reported updated=0, written=false, version 81 to 81 — idempotent, not merely repeatable. A structural comparison proved exactly 58 records changed and that only completed_at, completed_at_source and worker_seconds changed within them.

Completion sourceBeforeAfter
absent381
promotion_time90
provided120
stream_mtime965
terminal_event02

The one streamless record, r-20260812T084627660648-suite-native, was reported action=unusable and calibration_usable=false and left alone — its canonical byte hash d49427cc…c7c25d85 was identical before and after, which is the proof that a record without evidence was not repaired with a guess.

What the repaired ledger then says about the letters. Before the repair, uniform-worker-dispatch reported a mean of 252.7 minutes per node with a 405.9-minute spread. After it, the per-plan means collapse into a plausible band and the totals become comparable against the declared letters read as hours:

PlanDeclaredAs hoursMeasured worker-timeRuns
inflight-visibility (complete)M2.0 h0.65 h7
derived-gate-stateXL8.0 h2.3 h27
effort-calibration (partial)L4.0 h1.2 h13
budget-aware-dispatchM2.0 h0.60 h4
north-star-orientation (partial)M2.0 h0.27 h3
uniform-worker-dispatchL4.0 h7.8 h11

Read carefully, because two caveats matter more than the headline. uniform-worker-dispatch still carries a single 413-minute run against a 1.1-minute minimum; without it the plan sits near 0.9 h, so that row is one outlier rather than a counter-example, and it is a candidate for the same scrutiny the repair just applied. And measured worker-time is not elapsed work: it counts only what a worker process spent, excluding every minute the orchestrator spent scoping, reviewing, merging and recording — which in this session was a large fraction of the wall clock. So the honest statement is that the legacy letters over-estimate worker hours by roughly threefold on completed plans, not that the work was three times cheaper than believed.

Four runs are excluded as scope-changed, exactly as intended: three of this sprint's own widened nodes plus one earlier. They are counted where a reader can see them rather than averaged away.

Negative result — the backend's stream carries no timestamps, so the inflation survives

The node above was specified against a synthetic stream and passes on one. Promoting the two real runs of this same wave through the new code showed the fix does not reach the defect it was written for: both real runs recorded completed_at_source=promotion_time, because this backend's --json stream contains no timestamp of any kind. Every event in a full 296 KB stream carries only type, item or usage keys; a scan for any key containing time, ts, stamp or at returned none. The terminal turn.completed event supplies usage — which is why the token half of the fix works on real runs — and no clock.

So the measurable residue is unchanged in kind and only smaller in size than the 415-minute case that motivated the repair. Both figures below are inflated by the interval between a worker finishing and the orchestrator getting to its landing beat:

RunDispatchedTrue finish
(last stream write)
RecordedRecorded
worker time
TrueInflation
north-star-project-state17:22:02Z17:27:26Z17:29:46Z464 s324 s+140 s (+43%)
promotion-terminal-read17:22:13Z17:26:16Z17:31:25Z551 s243 s+308 s (+127%)

The bias is not random: it grows with how much landing work the orchestrator does between a worker finishing and that worker being promoted, which means it correlates with wave size and would systematically inflate exactly the runs dispatched in the widest waves. A speed factor fitted on these numbers would learn the orchestrator's promotion latency and attribute it to the worker.

The finish time is nonetheless recoverable without any backend cooperation: the last write to stream.jsonl is the terminal event's wall-clock, and the run directory survives promotion. Both figures in the table above were derived that way. The repair is therefore a third source ranked between the two that exist — event timestamp where a backend supplies one, else the stream's last-write time, else promotion wall-clock — each recorded distinctly so no reader can mistake a proxy for a measurement. Landed below.

Three ranked completion sources, and proxies kept out of calibration

Commit e57b28breckon/crew.py, reckon/ledger.py, tests/test_ledger.py; 168 insertions, 42 deletions across 3 files. Gate passed: 105 passed, 0 failed, 6.3 s, 5 new test functions plus one updated fallback regression.

The vocabulary is three ranked automatic values — terminal_event, stream_mtime, promotion_time — so a record always states which of them produced its figure.

CaseResult
Event timestamp availableRanks above filesystem time and is used
No event timestampResolves the newest mtime across stream.jsonl and every resume-*.jsonl
A resumed runEither the original or a resume stream can win by being newest, so a run that blocked and was answered is not read as finishing when it first stopped
No surviving streamRecords promotion_time explicitly
Calibration inputeffort_report excludes promotion_time records and reports excluded_unusable_completion at both report and plan-row level

The resumed-run case was found by measurement rather than by design: a resumed worker writes resume-1.jsonl and never touches the original stream again, so a naive last-write proxy read one of this sprint's own runs as finishing 11 minutes early — wrong in the opposite direction from the promotion-time inflation, and therefore invisible to any check that only looked for overstatement.

The exclusion is what makes the whole repair safe rather than merely better: a proxy duration is still a proxy, so rather than being quietly averaged in, a promotion_time record is withheld from calibration and counted where a reader can see how many there are.