@@ -457,9 +457,10 @@ public void saveGraph(UserContext context, Entity entity) {
457457 "teaql.mutation.kind" , "save" )));
458458 try {
459459 MutationIntent intent = MutationIntent .of (entity .getComment ());
460- checkAndFix (context , entity );
461460 // Get entity's own EntityMutationLedger
462461 EntityMutationLedger entityMutationLedger = ((BaseEntity ) entity ).getEntityMutationLedger ();
462+ entityMutationLedger .requireSaveable ();
463+ checkAndFix (context , entity );
463464
464465 // Allocate identifiers before roots are merged. New children commonly
465466 // receive their field updates while their id is still null, so those
@@ -573,12 +574,30 @@ public void saveGraph(UserContext context, Entity entity) {
573574 Collections .newSetFromMap (new IdentityHashMap <>()));
574575 throw failure ;
575576 }
576- dynamicReadbacks .forEach ((key , values ) -> {
577- BaseEntity target = realEntities .get (key );
578- target .__internalHydrateDynamicFields (values , target .__internalLoadState ().withDynamicSelection (values .selectedCodes ()));
579- });
580- completeLedgerPlan (context , completed );
581- entityMutationLedger .clearCurrentChangeSet ();
577+ CommittedStateReconciliationException reconciliationOutcome = null ;
578+ try {
579+ dynamicReadbacks .forEach ((key , values ) -> {
580+ BaseEntity target = realEntities .get (key );
581+ target .__internalHydrateDynamicFields (values , target .__internalLoadState ().withDynamicSelection (values .selectedCodes ()));
582+ });
583+ completeLedgerPlan (completed );
584+ entityMutationLedger .clearCurrentChangeSet ();
585+ } catch (RuntimeException reconciliationFailure ) {
586+ // The provider has returned from its transaction. The graph may be
587+ // partially reconciled, so neither its pending ledger nor its
588+ // entity instances are safe to save again without a fresh load.
589+ entityMutationLedger .markCommittedStateRequiresReload ();
590+ reconciliationOutcome = new CommittedStateReconciliationException ();
591+ }
592+ try {
593+ // The provider has already committed. A failed in-memory readback
594+ // must not silently suppress the independent audit delivery.
595+ deliverCommittedAuditEvents (context , completed );
596+ } catch (AuditDeliveryAfterCommitException auditFailure ) {
597+ if (reconciliationOutcome == null ) throw auditFailure ;
598+ reconciliationOutcome .addSuppressed (auditFailure );
599+ }
600+ if (reconciliationOutcome != null ) throw reconciliationOutcome ;
582601 telemetryScope .success ();
583602 } catch (RuntimeException | Error error ) {
584603 telemetryScope .failure (error );
@@ -1102,18 +1121,34 @@ private List<PendingMutation> executeLedgerPlan(
11021121 return completed ;
11031122 }
11041123
1105- private void completeLedgerPlan (UserContext context , List <PendingMutation > completed ) {
1124+ private void completeLedgerPlan (List <PendingMutation > completed ) {
11061125 for (PendingMutation mutation : completed ) {
11071126 applyPersistedEntity (mutation .descriptor (), mutation .target (), mutation .result ());
1108- emitAuditEvent (
1109- context , mutation .target (), mutation .auditKind (), mutation .changedValues (),
1110- mutation .governance (), mutation .intent (), mutation .traceChain (), mutation .redactions ());
11111127 mutation .target ().clearUpdatedProperties ();
1112- // A committed delete supersedes any staged extension edits too.
1113- mutation .target ().__internalClearDynamicMutations ();
11141128 }
11151129 }
11161130
1131+ private void deliverCommittedAuditEvents (UserContext context , List <PendingMutation > completed ) {
1132+ int failures = 0 ;
1133+ for (PendingMutation mutation : completed ) {
1134+ try {
1135+ failures += emitAuditEvent (
1136+ context , mutation .target (), mutation .auditKind (), mutation .changedValues (),
1137+ mutation .governance (), mutation .intent (), mutation .traceChain (), mutation .redactions (),
1138+ mutation .result ().persistedEntity ().getVersion ());
1139+ } catch (RuntimeException failure ) {
1140+ // The provider transaction has already committed. Continue delivery
1141+ // for the other graph members and never expose a retryable save error.
1142+ failures ++;
1143+ } finally {
1144+ // Preserve dynamic original values until the audit projection has
1145+ // observed them; then clear staged extension edits even on failure.
1146+ mutation .target ().__internalClearDynamicMutations ();
1147+ }
1148+ }
1149+ if (failures != 0 ) throw new AuditDeliveryAfterCommitException (failures );
1150+ }
1151+
11171152 private Map <String , Object > snapshotChanges (Map <String , Object > changes ) {
11181153 return Collections .unmodifiableMap (new LinkedHashMap <>(changes ));
11191154 }
@@ -1411,13 +1446,13 @@ private List<MutationResult> mutateBatchWithTelemetry(
14111446 }
14121447 }
14131448
1414- private void emitAuditEvent (
1449+ private int emitAuditEvent (
14151450 UserContext context ,
14161451 Entity entity ,
14171452 MutationAuditKind kind ,
14181453 Map <String , Object > changedValues ,
14191454 MutationGovernanceSnapshot governance , MutationIntent intent , List <TraceNode > traceChain ,
1420- SqlIntentRedactions redactions ) {
1455+ SqlIntentRedactions redactions , Long committedVersion ) {
14211456 List <AuditFieldChange > changes = new ArrayList <>();
14221457 if (changedValues != null ) {
14231458 for (Map .Entry <String , Object > entry : changedValues .entrySet ()) {
@@ -1447,7 +1482,7 @@ private void emitAuditEvent(
14471482 context .getAttribute (GeneratedSchemaBootstrap .AUDIT_ACTOR_ATTRIBUTE , String .class ),
14481483 context .getAttribute (GeneratedSchemaBootstrap .AUDIT_CATEGORY_ATTRIBUTE , String .class ),
14491484 intent .auditReason (),
1450- entity . getVersion () ,
1485+ committedVersion ,
14511486 java .time .Instant .now (),
14521487 governance );
14531488
@@ -1457,18 +1492,29 @@ private void emitAuditEvent(
14571492 "teaql.mutation.kind" , kind .name ().toLowerCase (Locale .ROOT ),
14581493 "teaql.audit.changed_field_count" , changes .size ())));
14591494 try {
1495+ int failures = 0 ;
1496+ // The server-owned and application sinks are independent delivery
1497+ // attempts. One failure must not suppress the other after commit.
1498+ if (logSink != null ) {
1499+ try {
1500+ logSink .writeAuditEvent (context ,
1501+ LogPrivacy .audit (rawEvent , LogPrivacy .plaintextEnabled (), redactions ));
1502+ } catch (RuntimeException failure ) {
1503+ failures ++;
1504+ }
1505+ }
14601506
1461- // The standard sink is server-owned by TeaQLRuntime and cannot be replaced by
1462- // dynamic input or an application capability registered on UserContext.
1463- if ( logSink != null ) {
1464- logSink . writeAuditEvent (context , LogPrivacy . audit (rawEvent , LogPrivacy . plaintextEnabled () , redactions ));
1465- }
1466-
1467- AppAuditEventSink appSink = context . capability ( AppAuditEventSink . class );
1468- if ( appSink != null ) {
1469- appSink . onAuditEvent ( context , buildSafeAuditEvent ( rawEvent , redactions ) );
1470- }
1471- telemetryScope . success () ;
1507+ AppAuditEventSink appSink = context . capability ( AppAuditEventSink . class );
1508+ if ( appSink != null ) {
1509+ try {
1510+ appSink . onAuditEvent (context , buildSafeAuditEvent (rawEvent , redactions ));
1511+ } catch ( RuntimeException failure ) {
1512+ failures ++;
1513+ }
1514+ }
1515+ if ( failures == 0 ) telemetryScope . success ( );
1516+ else telemetryScope . failure ( new AuditDeliveryAfterCommitException ( failures ));
1517+ return failures ;
14721518 } catch (RuntimeException | Error error ) {
14731519 telemetryScope .failure (error );
14741520 throw error ;
0 commit comments