PlyReconcileException
One ply's failure inside a live CompositeLoom session — attaching, detaching, or processing an inbound frame — raised through CompositeLoom(onPlyFailure = …).
A ply is built from a consumer-authored Loom, so Loom.capability(), Loom.weave() and the ply Seam's own close() can all throw; and a ply's inbound frames are peer-supplied bytes, so a merely buggy or version-skewed peer can send one that does not decode. The composite absorbs all of those — one ply must not be able to stop the reconciliation of the others, still less kill a pump for the life of the seam (#1784), and no peer may crash the process with a short frame (#1788) — but absorbing them silently would leave a consumer with a ply that never appears, or frames that quietly vanish, and nothing at all to look at. kuilt-core is logger-free by contract, so this is the signal: the composite raises it, and the consumer's own logger records it.
It carries the ply's plyId, which phase failed, and the originating cause — identity and exception, never a bare count, because "a ply failed" is not a diagnosis and "which ply, doing what, and why" is.
The composite keeps going regardless: the other plies in the same pass still attach and detach, a ply that failed to attach is retried on the next desired-set emission (it is left un-live, not recorded as failed), and a ply whose inbound frame failed keeps delivering the frames after it. Ignoring this signal therefore costs observability, never liveness.
(The name predates Phase.INBOUND, which is not a reconciliation step. It is kept because this is the one type onPlyFailure carries and the whole point of that hook is a single per-ply failure signal.)