link

open override fun link(key: ExtractionRunKey, propositionIds: Collection<String>): Int

Records the links, joining a caller's transaction when there is one.

Propagation is REQUIRED, and that is a decision rather than a default. The alternative, REQUIRES_NEW, would guarantee that a failure here can never affect a caller's transaction — but it would also suspend that transaction, and a new one cannot see rows the suspended one has not committed. A host that wraps extraction in @Transactional saves its propositions in that transaction, so under REQUIRES_NEW every one of them would be invisible to the scope check and lineage would fail closed on every such extraction. Joining the caller means lineage resolves the claims it is about, and the two commit or roll back together.

What joining costs, stated exactly. Spring marks a participating transaction rollback-only when an inner method throws, and it does so here: Drivine's manager overrides doSetRollbackOnly and the shared transaction object's rollbackOnly flag is set. The caller's commit survives anyway because that flag is write-only — DrivineTransactionObject does not implement SmartTransactionObject, so Spring's isGlobalRollbackOnly cannot see it, and Drivine never reads it in doCommit. The marking is propagated and then dropped. That is behaviour, not contract, so it is pinned rather than assumed: lineage inside a caller's transaction sees its writes and cannot condemn it goes red if Drivine implements SmartTransactionObject or starts reading the flag.

The guarantee covers application-level failures only. Everything this method raises by itself — tenant guard, run-not-found, scope rejection, batch-changed — is thrown from Kotlin after its statements succeeded, so the Bolt transaction is healthy and a caller that catches really can carry on. A statement that fails at the server is not covered: it terminates the transaction beneath Spring, and no catch can undo that. A deadlock between two runs linking overlapping propositions is the realistic version: nothing orders the node locks two concurrent MERGE batches take over the same propositions. a server-side failure inside a caller's transaction is the window best-effort does not cover demonstrates it and measures the cost: the caller's later writes are lost with it.

That window closes when the run coordinator commits claims before recording lineage (DICE #67 slice 10), which is what makes lineage a genuinely separate write rather than one that shares a caller's fate. Until then it is a real limitation of running extraction inside an ambient transaction, and hosts that do not wrap extraction are unaffected.