Observe sender ledger continuity and track supervised transition
Some checks failed
Transactional mail acceptance / mail (push) Failing after 31s
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3
This commit is contained in:
tegwick 2026-10-31 16:23:20 +01:00
parent c1c14a0ea1
commit c2ce58f10d
6 changed files with 526 additions and 0 deletions

View file

@ -17,6 +17,7 @@
| workplan | EMAIL-WP-0007 | finished | — | workplans/EMAIL-WP-0007-ledger-backup-recovery.md |
| workplan | EMAIL-WP-0008 | finished | — | workplans/EMAIL-WP-0008-scoped-transactional-callers.md |
| workplan | EMAIL-WP-0009 | finished | — | workplans/EMAIL-WP-0009-wave2-resource-right-sizing.md |
| workplan | EMAIL-WP-0010 | active | — | workplans/EMAIL-WP-0010-platform-sender-transition.md |
| task | EMAIL-WP-0001-T01 | done | — | workplans/EMAIL-WP-0001-repo-onboarding.md |
| task | EMAIL-WP-0001-T02 | done | — | workplans/EMAIL-WP-0001-repo-onboarding.md |
| task | EMAIL-WP-0001-T03 | done | — | workplans/EMAIL-WP-0001-repo-onboarding.md |
@ -63,3 +64,7 @@
| task | EMAIL-WP-0008-T03 | done | — | workplans/EMAIL-WP-0008-scoped-transactional-callers.md |
| task | EMAIL-WP-0008-T04 | done | — | workplans/EMAIL-WP-0008-scoped-transactional-callers.md |
| task | EMAIL-WP-0009-T01 | done | — | workplans/EMAIL-WP-0009-wave2-resource-right-sizing.md |
| task | EMAIL-WP-0010-T01 | done | — | workplans/EMAIL-WP-0010-platform-sender-transition.md |
| task | EMAIL-WP-0010-T02 | todo | — | workplans/EMAIL-WP-0010-platform-sender-transition.md |
| task | EMAIL-WP-0010-T03 | wait | — | workplans/EMAIL-WP-0010-platform-sender-transition.md |
| task | EMAIL-WP-0010-T04 | todo | — | workplans/EMAIL-WP-0010-platform-sender-transition.md |

View file

@ -0,0 +1,64 @@
{
"consistent_read_transaction": true,
"ledger_fingerprint": {
"schema_sha256": "13148e5a9c6e01cc593bb07278a8649b13e43b26c30b0ed1a6e15995f7b00732",
"tables": {
"caller_buckets": {
"rows": 1,
"sha256": "14eac0ee940c87217439ff255f96c92c746be1bcd87a76db350647cea2aaddf0"
},
"deliveries": {
"rows": 2,
"sha256": "a0387716815b2ec191187cb34bb3252b9cf51f5fb15a2019918a9cf1737c061e"
},
"send_attempts": {
"rows": 0,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"suppressions": {
"rows": 1,
"sha256": "ae26d10dfec16f1b626292b342a5e86afd1858fcaed7c29d4e68e7cf821183d7"
},
"verification_attempts": {
"rows": 121,
"sha256": "4a20f2865438daaf9ba9192019ebf4d689778560cb7b27fd118fc9cea1ea7f39"
},
"verifications": {
"rows": 121,
"sha256": "ffaf67ac6665d3d7d9fdd50d5feb57f75c70061d773b689669421f1017454334"
}
},
"user_version": 0
},
"mail_sent": false,
"needs_outcome_reconciliation": false,
"observed_at": "2026-10-31T15:16:41.014728+00:00",
"outcomes": {
"accepted_invitations": 2,
"accepted_verifications": 121,
"canceled_challenges": 0,
"consumed_challenges": 0,
"failed_invitations": 0,
"failed_verifications": 0,
"orphan_verification_attempts": 0,
"suppressed_recipients": 1,
"unknown_invitations": 0,
"unknown_verifications": 0,
"unlinked_verifications": 0,
"unrecognized_attempt_states": 0
},
"private_rows_printed": false,
"rollout_authorized": false,
"schema": "email-connect.sender-ledger-observation.v1",
"writers_fenced": false,
"runtime": {
"deployment_uid": "2e98e52a-a2d1-4c52-9ac8-6efbc0a18e72",
"generation": 74,
"image": "forgejo.coulomb.social/coulomb/email-connect@sha256:5d79fd1fec15d3a279383820f170d263de3a55f5308df01a0a34732ebaea6d3d",
"sender": "forgejo@coulomb.social",
"deployment_spec_unchanged_during_observation": true,
"transactional_sha256": "36fdfd8cb58f10148cd491a7e34d7aea798e4289fce760af70a48776bede204d",
"packaged_source_matches_checkout": true
},
"observer_sha256": "262d6d52d5c10fb8583e0828ed34945b647f1d53bf9de5286374fef85cf76a8d"
}

View file

@ -0,0 +1,68 @@
# Sender transition: ledger and shutdown boundary
The current receiver remains generation 74/image 5d79fd1f, using
forgejo@coulomb.social. The platform sender's credential foundation is prepared
under RPF-WP-0068 but unadmitted. EMAIL-WP-0010 now owns the separate configuration
executor, attended switch and native return, supporting CSOC-WP-0015-T02.
At 15:16:41Z, `inspect-sender-ledger.py` read the native ledger in one read-only
SQLite transaction. All six tables pass integrity/fingerprint checks, including
committed WAL. Aggregate results: 2 accepted invitations, 121 accepted verification
attempts, 1 suppression, no unknown outcomes, orphan attempts, unrecognized states
or unlinked verification rows. No private row values were printed. Evidence:
`sender-ledger-native-2026-10-31.json`. The receipt binds the exact observer source
and native transactional source, which matches the checkout. The deployment spec
was unchanged across observation; no resource or sender change was made.
## What the observation means
The successful invitation path writes its provider receipt into deliveries but
leaves the corresponding send_attempts row in_flight. That row by itself is not
evidence of uncertainty: `delivery_status` and `reserve_send` check deliveries
first. Verification sends instead explicitly update their attempt to accepted.
The observer follows both conventions and reports only counts and hashes.
The source uses `wsgiref.simple_server` without an explicit SIGTERM handler. The
Deployment has 15 seconds termination grace, but that setting does not establish
application-level draining. Termination between SMTP acceptance and ledger commit
can retain an unknown outcome. Existing source refuses blind resubmission; a
configuration change must preserve this behavior and the whole ledger. The new
observer neither alters reservations nor decides whether a provider accepted mail.
No uncertain outcomes were present at this observation. Writers were active;
new requests can arrive afterward. Therefore this does not establish a drained
receiver or a valid execution-time baseline. Unlinked historical challenges are
reported separately; they may include superseded retries and are not automatically
classified as uncertain sends or relabeled as accepted.
## Remaining executor work
The executor must combine a verified foundation return with current identity/spec
guards, writer quiescence and a fresh recoverable ledger point. It must retain
phase evidence across lost replies and distinguish source configuration rollback
from data restoration. A failed rollout must not overwrite accepted/unknown sends,
consumed or canceled handles, suppression or quotas, including later legitimate
writes. The old reader/projection remains retained for guarded rollback; no
mailbox credential is copied into source, chat or a public receipt.
The existing consumer dry-run candidate changes only the sender and credential
references, but is not an execution procedure. Exact supervised controller and
interruption tests are EMAIL-WP-0010-T02; live switch and independent postflight
are T03/T04. Productive mail authentication and inbox/return handling remain
CSOC-WP-0015-T02/T05. No new execution approval is requested by this observation.
## Validation
Twelve new synthetic cases pass. They compare the exact fingerprint format with
the existing archive implementation, preserve all rows, distinguish accepted
in_flight invitation rows from unknowns, retain unknown retry refusal after reopen,
classify verification/history cases, and prove that a concurrent committed WAL
write cannot split the fingerprint and outcome counts across snapshots. Missing,
symlinked, oversized and unexpected-schema input refuses; CLI failures redact
paths. Tests use no production URLs/accounts or provider sends.
Owner default suite: 192 passed, 21 skipped in 13.21s. Those 21 existing tests require the
opt-in container mail harness; no new test is skipped. This is sufficient evidence
for the read-only observer and is not a delivered-message or rollout acceptance
claim. Ruff passes for both changed Python files. Log:
`/tmp/email-sender-ledger-check-20261031.log`.

View file

@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Read-only, coherent ledger observation for a supervised sender transition.
No application startup, migration, SMTP, receipt mutation or outcome repair.
An observation while writers are active never proves a drained receiver.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import sqlite3
import stat
from contextlib import closing
from datetime import UTC, datetime
from pathlib import Path
TABLES = {
"deliveries", "verifications", "suppressions", "send_attempts",
"verification_attempts", "caller_buckets",
}
MAX_BYTES = 128 * 1024**2
class Refused(RuntimeError):
pass
def inspect(path):
path = Path(path).absolute()
if (any(p.is_symlink() for p in [path, *path.parents])
or not stat.S_ISREG(path.stat().st_mode)):
raise Refused("real_ledger_required")
with closing(sqlite3.connect(path.as_uri() + "?mode=ro", uri=True, timeout=5)) as db:
db.execute("PRAGMA trusted_schema=OFF")
db.execute("PRAGMA query_only=ON")
db.execute("BEGIN")
pages = db.execute("PRAGMA page_count").fetchone()[0]
size = db.execute("PRAGMA page_size").fetchone()[0]
if not 0 < pages * size <= MAX_BYTES:
raise Refused("ledger_size_outside_contract")
if db.execute("PRAGMA integrity_check").fetchall() != [("ok",)]:
raise Refused("ledger_integrity_failed")
schema = list(db.execute(
"SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name"))
tables = {row[1] for row in schema
if row[0] == "table" and not row[1].startswith("sqlite_")}
if tables != TABLES:
raise Refused("unexpected_ledger_schema")
# Same representation as ledger_archive.fingerprint, but this read and
# every outcome query share one transaction, including committed WAL.
fingerprint = {
"schema_sha256": hashlib.sha256(json.dumps(schema).encode()).hexdigest(),
"user_version": db.execute("PRAGMA user_version").fetchone()[0],
"tables": {},
}
for table in sorted(tables):
rows = [json.dumps(row, separators=(",", ":"),
default=lambda value: {"bytes_hex": value.hex()}).encode()
for row in db.execute('SELECT * FROM "' + table + '"')]
fingerprint["tables"][table] = {
"rows": len(rows),
"sha256": hashlib.sha256(b"\n".join(sorted(rows))).hexdigest(),
}
def count(sql):
return db.execute(sql).fetchone()[0]
outcomes = {
"accepted_invitations": count("SELECT COUNT(*) FROM deliveries"),
# Successful reserve_send rows stay in_flight; deliveries takes
# precedence in the application's status and retry logic.
"unknown_invitations": count(
"SELECT COUNT(*) FROM send_attempts a WHERE state='in_flight' "
"AND NOT EXISTS (SELECT 1 FROM deliveries d WHERE d.event_id=a.event_id)"),
"failed_invitations": count(
"SELECT COUNT(*) FROM send_attempts a WHERE state='failed' "
"AND NOT EXISTS (SELECT 1 FROM deliveries d WHERE d.event_id=a.event_id)"),
"accepted_verifications": count(
"SELECT COUNT(*) FROM verification_attempts WHERE state='accepted'"),
"unknown_verifications": count(
"SELECT COUNT(*) FROM verification_attempts WHERE state='in_flight'"),
"failed_verifications": count(
"SELECT COUNT(*) FROM verification_attempts WHERE state='failed'"),
"unlinked_verifications": count(
"SELECT COUNT(*) FROM verifications v WHERE NOT EXISTS "
"(SELECT 1 FROM verification_attempts a WHERE a.request_id=v.request_id)"),
"unrecognized_attempt_states": count(
"SELECT COUNT(*) FROM send_attempts WHERE state NOT IN ('in_flight','failed')"
) + count(
"SELECT COUNT(*) FROM verification_attempts "
"WHERE state NOT IN ('in_flight','accepted','failed')"),
"orphan_verification_attempts": count(
"SELECT COUNT(*) FROM verification_attempts a WHERE NOT EXISTS "
"(SELECT 1 FROM verifications v WHERE v.request_id=a.request_id)"),
"consumed_challenges": count(
"SELECT COUNT(*) FROM verifications WHERE consumed_at IS NOT NULL"),
"canceled_challenges": count(
"SELECT COUNT(*) FROM verifications WHERE canceled_at IS NOT NULL"),
"suppressed_recipients": count("SELECT COUNT(*) FROM suppressions"),
}
db.rollback()
return {
"schema": "email-connect.sender-ledger-observation.v1",
"observed_at": datetime.now(UTC).isoformat(),
"ledger_fingerprint": fingerprint,
"outcomes": outcomes,
"needs_outcome_reconciliation": any(outcomes[key] for key in (
"unknown_invitations", "unknown_verifications",
"unrecognized_attempt_states",
"orphan_verification_attempts")),
"consistent_read_transaction": True,
"writers_fenced": False,
"rollout_authorized": False,
"mail_sent": False,
"private_rows_printed": False,
}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--database", type=Path, required=True)
args = parser.parse_args()
try:
result = inspect(args.database)
except (OSError, ValueError, sqlite3.Error, Refused):
raise SystemExit("ledger_observation_refused") from None
print(json.dumps(result, sort_keys=True))
if __name__ == "__main__":
main()

138
tests/test_sender_ledger.py Normal file
View file

@ -0,0 +1,138 @@
"""Synthetic ledger observations; never connects to an SMTP service."""
import importlib.util
import json
import sqlite3
import subprocess
import sys
from pathlib import Path
import pytest
from test_ledger_backup import synthetic_store
from email_connect.transactional import ProviderError, SQLiteDeliveryStore
SCRIPT = Path(__file__).resolve().parents[1] / "scripts/inspect-sender-ledger.py"
spec = importlib.util.spec_from_file_location("inspect_sender_ledger", SCRIPT)
observer = importlib.util.module_from_spec(spec)
spec.loader.exec_module(observer)
def test_matches_archive_fingerprint_and_preserves_every_row(tmp_path, monkeypatch):
monkeypatch.syspath_prepend(str(SCRIPT.parent))
from ledger_archive import fingerprint
path = tmp_path / "ledger.sqlite3"
synthetic_store(path)
before = fingerprint(path)
result = observer.inspect(path)
assert result["ledger_fingerprint"] == before == fingerprint(path)
assert not result["mail_sent"] and not result["private_rows_printed"]
assert not result["writers_fenced"] and not result["rollout_authorized"]
assert result["consistent_read_transaction"]
assert "example.test" not in json.dumps(result)
def test_accepted_invitation_in_flight_row_is_not_unknown(tmp_path):
path = tmp_path / "ledger.sqlite3"
store = SQLiteDeliveryStore(str(path))
assert store.reserve_send("accepted-event", "fingerprint")
store.record("accepted-event", "synthetic-provider-reference")
assert store.db.execute("SELECT state FROM send_attempts").fetchone()[0] == "in_flight"
result = observer.inspect(path)
assert result["outcomes"]["accepted_invitations"] == 1
assert result["outcomes"]["unknown_invitations"] == 0
assert not result["needs_outcome_reconciliation"]
assert store.reserve_send("accepted-event", "fingerprint") is False
store.db.close()
def test_unknown_invitation_survives_inspection_and_restart(tmp_path):
path = tmp_path / "ledger.sqlite3"
store = SQLiteDeliveryStore(str(path))
store.reserve_send("uncertain-event", "fingerprint")
store.db.close()
result = observer.inspect(path)
assert result["outcomes"]["unknown_invitations"] == 1
assert result["needs_outcome_reconciliation"]
store = SQLiteDeliveryStore(str(path))
with pytest.raises(ProviderError, match="delivery_outcome_unknown"):
store.reserve_send("uncertain-event", "fingerprint")
assert observer.inspect(path)["ledger_fingerprint"] == result["ledger_fingerprint"]
store.db.close()
@pytest.mark.parametrize("state", ["in_flight", "accepted", "failed"])
def test_verification_outcomes_and_unlinked_history_are_distinct(tmp_path, state):
path = tmp_path / "ledger.sqlite3"
store = SQLiteDeliveryStore(str(path))
payload = {"tenant": "fixture", "client_id": "client", "registration_id": "one",
"normalized_email": "one@example.test", "preferred_username": "one"}
request, _ = store.reserve_verification(payload, "synthetic-hash", "2030-01-01T00:00:00Z")
if state == "accepted":
store.verification_accepted(request, "synthetic-reference")
elif state == "failed":
store.verification_failed(request, ProviderError("definitive", retryable=False))
payload["registration_id"] = "historical"
store.create_verification(payload, "other-hash", "2020-01-01T00:00:00Z")
result = observer.inspect(path)
label = {"in_flight": "unknown", "accepted": "accepted", "failed": "failed"}[state]
assert result["outcomes"][label + "_verifications"] == 1
assert result["outcomes"]["unlinked_verifications"] == 1
assert result["needs_outcome_reconciliation"] == (state == "in_flight")
store.db.close()
def test_concurrent_wal_commit_cannot_split_the_observation(tmp_path, monkeypatch):
path = tmp_path / "ledger.sqlite3"
store = SQLiteDeliveryStore(str(path))
store.db.execute("PRAGMA journal_mode=WAL")
store.reserve_send("accepted", "fp")
store.record("accepted", "reference")
real_connect = sqlite3.connect
changed = []
class ObservedConnection(sqlite3.Connection):
def execute(self, statement, *args, **kwargs):
if statement == "SELECT COUNT(*) FROM deliveries" and not changed:
changed.append(True)
store.reserve_send("later-uncertain", "fp2")
return super().execute(statement, *args, **kwargs)
monkeypatch.setattr(observer.sqlite3, "connect", lambda *a, **kw:
real_connect(*a, **kw, factory=ObservedConnection))
result = observer.inspect(path)
assert changed
assert result["ledger_fingerprint"]["tables"]["send_attempts"]["rows"] == 1
assert result["outcomes"]["unknown_invitations"] == 0
assert store.db.execute("SELECT COUNT(*) FROM send_attempts").fetchone()[0] == 2
store.db.close()
@pytest.mark.parametrize("invalid", ["missing", "symlink", "schema", "size"])
def test_invalid_inputs_are_refused_without_writing(tmp_path, monkeypatch, invalid):
path = tmp_path / "ledger.sqlite3"
if invalid != "missing":
store = SQLiteDeliveryStore(str(path))
store.db.close()
if invalid == "symlink":
target = tmp_path / "target.sqlite3"
path.rename(target)
path.symlink_to(target)
elif invalid == "schema":
with sqlite3.connect(path) as db:
db.execute("CREATE TABLE unexpected (value TEXT)")
elif invalid == "size":
monkeypatch.setattr(observer, "MAX_BYTES", 1)
before = path.read_bytes() if path.exists() else None
with pytest.raises((observer.Refused, OSError)):
observer.inspect(path)
assert (path.read_bytes() if path.exists() else None) == before
def test_cli_errors_do_not_echo_private_paths(tmp_path):
result = subprocess.run([sys.executable, str(SCRIPT), "--database",
str(tmp_path / "private-recipient@example.test")],
capture_output=True, text=True, timeout=10, check=False)
assert result.returncode != 0 and not result.stdout
assert result.stderr.strip() == "ledger_observation_refused"

View file

@ -0,0 +1,118 @@
---
id: EMAIL-WP-0010
type: workplan
title: "Supervised platform sender transition with ledger continuity"
domain: infotech
repo: email-connect
status: active
owner: codex
topic_slug: email-connect
created: "2026-10-31"
updated: "2026-10-31"
related:
- CSOC-WP-0015
- RPF-WP-0068
state_hub_workstream_id: "6d8e1363-e67e-5837-bb6f-e5a95f43d71b"
---
Owner execution dependency of CSOC-WP-0015-T02. The platform mailbox's additional
reader admission is RPF-WP-0068/CCR-2026-0056; this workplan does not duplicate or
self-approve that credential lane. Bernd/platform-root reply ownership is settled.
Existing caller admission, image, ledger, backup schedules and old custody remain
in scope for preservation. Productive sender/return handling stays in CSOC-WP-0015.
## T01 — Establish native ledger and receiver transition boundaries
```task
id: EMAIL-WP-0010-T01
status: done
priority: high
state_hub_task_id: "f5b03013-3c1b-5b1a-86e2-a96dd817fa00"
```
Completed 2026-10-31 with `scripts/inspect-sender-ledger.py` and
`docs/sender-ledger-native-2026-10-31.json`. The read-only helper fingerprints all
six tables and classifies outcomes in one SQLite transaction, including committed
WAL. Native generation 74/image 5d79fd1f retains forgejo@coulomb.social. Packaged
transactional source matches the owner checkout. Two invitation receipts and 121
verification attempts are accepted; zero unknown/unrecognized/orphan outcomes,
zero unlinked challenges, one suppression. No message body, address, handle,
credential or individual record was printed. No deployment or ledger write.
Successful invitations can retain an in_flight attempt; the deliveries row is
authoritative. The receiver has no explicit SIGTERM drain handler. A process exit
can leave an uncertain provider outcome, which must remain non-retryable. This
observation is not a writer fence, backup, outcome repair or approval. Twelve new
synthetic cases pass, including concurrent WAL writes, retry continuity, exact
archive-fingerprint compatibility and private-output refusal. Owner suite:
192 passed, 21 skipped (pre-existing opt-in container harness tests).
## T02 — Prepare and rehearse the supervised configuration executor
```task
id: EMAIL-WP-0010-T02
status: todo
priority: high
depends_on: [EMAIL-WP-0010-T01]
state_hub_task_id: "acb6f995-9177-5a3c-909a-feb152d6bfed"
```
Turn the existing five-operation consumer candidate into a concrete source-bound
sender executor after inspecting the actual service/caller boundary. Reuse the
already prepared foundation resources; do not recreate them. Pin cluster,
namespace, deployment, image, existing caller policy/projections and complete
before/after configuration. Preserve all unrelated deployment fields and old
credential custody. The actual source sender change is only forgejo@ -> platform@,
with the dedicated admitted SMTP projection and explicit username.
Prove writer quiescence, fresh recoverable ledger capture, outcome classification,
complete data preservation, private durable intent before every mutation and
independent runtime/no-send checks. Reuse existing ledger capture/rehearsal and
fixed projection proof. Review ambiguity after SIGTERM/lost replies, interruption
before/after each configuration change, and rollback with legitimate subsequent
ledger writes. Never restore a pre-release ledger, replay uncertain sends, reenable
the retired legacy bearer or delete old custody as a configuration rollback.
Prepare a bounded recovery procedure that refuses unrelated drift. Deliver the
exact review packet and passing synthetic/native dry-run evidence before asking
for execution; a read-only ledger snapshot alone does not close this task.
## T03 — Execute the admitted sender switch
```task
id: EMAIL-WP-0010-T03
status: wait
blocking_reason: "Requires the complete sender executor and verified RPF-WP-0068-T04 credential-owner return."
priority: high
depends_on: [EMAIL-WP-0010-T02, RPF-WP-0068-T04]
state_hub_task_id: "e0cbed16-f08f-50f7-8e15-633b3af29e88"
```
After the verified credential-owner return and concrete sender-packet review,
obtain the attended execution return through the existing owner deployment lane.
Do not treat mailbox ownership, credential admission, a prior image release or
the dry-run candidate as approval for an unprepared configuration change. Inspect
any existing attempt before replay; uncertain outcomes require the reviewed
recovery procedure. Preserve application/caller state and retained evidence.
## T04 — Verify the native sender return and reconcile sources
```task
id: EMAIL-WP-0010-T04
status: todo
priority: high
depends_on: [EMAIL-WP-0010-T03]
state_hub_task_id: "19485ff3-132c-567c-8c28-8c039e63c2b4"
```
Independently verify private durable receipts, complete intended spec, actual
process sender/credential references, Ready state, caller denials and ledger
continuity. Reconcile deployment sources to the verified runtime only after
success. Return bounded evidence to CSOC-WP-0015-T02. Delivered-message
authentication, selected inbox/return handling, suppression authority and
retention/productive verdicts remain live consumer requirements; this configuration
return does not establish them. No unsolicited mail is part of these checks.
## Completion
Finish after all four tasks have their stated native evidence. Hand any newly
discovered actionable residual to a live work record before finishing.