@@ -85,7 +85,7 @@ public bool TryGet(JobId id, out Job? job)
8585 /// <summary>Submit a job. The caller (SessionState) hands in the envelope; this method returns
8686 /// the <see cref="Job"/> to run asynchronously plus the <c>job.accepted</c> payload.
8787 /// <paramref name="inboundTraceId"/> propagates the envelope's <c>trace_id</c> per spec §11.</summary>
88- public async Task < ( Job Job , JobAcceptedPayload Accepted ) > SubmitAsync (
88+ public async Task < ( Job Job , JobAcceptedPayload Accepted , bool IsReplay ) > SubmitAsync (
8989 JobSubmitPayload submit ,
9090 SessionId sessionId ,
9191 string ? submitterPrincipal ,
@@ -113,7 +113,9 @@ public bool TryGet(JobId id, out Job? job)
113113 }
114114 if ( _jobs . TryGetValue ( existingRecord . JobId , out var existing ) )
115115 {
116- return ( existing , BuildAccepted ( existing ) ) ;
116+ // Spec §7.2: an idempotent replay returns the *same* job.accepted and MUST
117+ // NOT re-run the agent. Flag it so the caller skips Resolve/RunAsync.
118+ return ( existing , BuildAccepted ( existing ) , true ) ;
117119 }
118120 }
119121 else
@@ -153,7 +155,7 @@ public bool TryGet(JobId id, out Job? job)
153155 _idempotency [ idemKey ] = new IdempotencyRecord ( jobId , fingerprint , _time . GetUtcNow ( ) ) ;
154156 }
155157
156- return ( job , BuildAccepted ( job ) ) ;
158+ return ( job , BuildAccepted ( job ) , false ) ;
157159 }
158160
159161 private void AssertChildLeaseIsSubset ( string parentJobId , Lease child , LeaseConstraints ? childConstraints )
@@ -440,15 +442,19 @@ private async Task RunRuntimeWatchdog(Job job, TimeSpan limit, CancellationToken
440442 private static bool IsTerminal ( JobStatus s ) =>
441443 s is JobStatus . Success or JobStatus . Error or JobStatus . Cancelled or JobStatus . TimedOut ;
442444
443- /// <summary>Cancel a running job. Only the original submitter may cancel; subscribers may not (spec §7.6).</summary>
444- public bool Cancel ( JobId jobId , string ? requesterPrincipal , string ? reason )
445+ /// <summary>Cancel a running job. Cancellation authority is scoped to the *submitting session*
446+ /// (spec §7.6, §13.3, §14): only the session that submitted the job may cancel it. Subscription —
447+ /// even from another session of the same principal — does NOT confer cancel authority. Returns
448+ /// <see langword="false"/> if the job does not exist; throws <see cref="PermissionDeniedException"/>
449+ /// when the requesting session is not the submitter.</summary>
450+ public bool Cancel ( JobId jobId , SessionId requesterSession , string ? reason )
445451 {
446452 if ( ! _jobs . TryGetValue ( jobId , out var job ) ) return false ;
447- // Spec §7.6: subscription does NOT grant cancel authority; only submitter may cancel.
448- if ( requesterPrincipal is not null && job . SubmitterPrincipal is not null &&
449- ! string . Equals ( requesterPrincipal , job . SubmitterPrincipal , StringComparison . Ordinal ) )
453+ // Fail closed: compare the submitting session, never the principal. A null/foreign requester
454+ // must not be able to bypass the check (spec §14: "Subscription MUST NOT confer cancel authority").
455+ if ( ! job . SessionId . Equals ( requesterSession ) )
450456 {
451- throw new PermissionDeniedException ( "Subscribers MUST NOT cancel jobs (spec §7.6)" ) ;
457+ throw new PermissionDeniedException ( "Only the submitting session may cancel a job (spec §7.6, §14 )" ) ;
452458 }
453459 job . CancellationSource . Cancel ( ) ;
454460 return true ;
0 commit comments