Skip to content

Diagram — Job Signing Sequence

Referenced from docs/technical/ARCHITECTURE.md. Covers both dispatch paths from sign to report.

sequenceDiagram
    actor Op as Operator CLI
    participant Signer as JobSigner
    participant Inbox as OneDrive inbox
    participant Queue as QueueService<br/>(target's own listen/serve)
    participant Gate as AvailabilityGate
    participant Governor as GovernorGate
    participant Exec as IJobExecutor
    participant Outbox as OneDrive outbox
    participant Peer as DispatchServer /dispatch<br/>(target's own serve)

    rect rgb(235, 245, 255)
    note over Op,Outbox: Path A — enqueue (async, OneDrive-queued)
    Op->>Signer: build DispatchJob, CanonicalString v2 (…|jobClass)
    Signer->>Op: HMAC-SHA256(key, canonical) → Sig
    Op->>Inbox: atomic write {id}.json (temp file + File.Move)
    Queue->>Inbox: poll, read oldest-first
    Queue->>Queue: SchemaVersion <= CurrentSchemaVersion?
    Queue->>Queue: JobSigner.Verify (FixedTimeEquals)
    Queue->>Queue: job id shape, HarnessRegistry.IsKnown(kind)
    Queue->>Queue: replay check (done/outbox already has this id?)
    Queue->>Queue: wrong-target / cancel-before-start check
    Queue->>Gate: AvailabilityGate.Evaluate(jobClass, freshSignals, now)
    alt refused
        Gate-->>Queue: Refused — reason, blockLevel
        Queue->>Inbox: job file left untouched (requeue next poll)
    else allowed
        Queue->>Governor: ShouldDefer(tier, critical)?
        alt RED/BLACKOUT and non-critical
            Governor-->>Queue: defer
            Queue->>Inbox: job file left untouched (requeue next poll)
        else run it
            Queue->>Queue: move inbox → processing
            Queue->>Exec: ExecuteAsync(job)
            Exec-->>Queue: DispatchResult
            Queue->>Outbox: atomic write {id}.json
            Queue->>Queue: move processing → archive/done
        end
    end
    end

    rect rgb(255, 245, 235)
    note over Op,Peer: Path B — send (sync, direct LAN peer)
    Op->>Signer: build DispatchJob, sign (same canonical)
    Op->>Peer: POST /dispatch, X-Fleet-Auth: sha256(key) hex
    Peer->>Peer: VerifyBearer (FixedTimeEquals) — before reading body
    Peer->>Peer: read body (max 256KB)
    Peer->>Peer: SchemaVersion check → 422 if too new
    Peer->>Peer: JobSigner.Verify → 401 if invalid
    Peer->>Peer: job.Target == this PC? → 400 if not
    Peer->>Peer: replay (done/outbox) → 409 if seen
    Peer->>Peer: cancel-before-start marker? → 200 pre-cancelled if so
    Peer->>Gate: AvailabilityGate.Evaluate(jobClass, freshSignals, now)
    alt refused
        Gate-->>Peer: Refused
        Peer-->>Op: 503 {deferred:true, unavailable:true, reason, blockLevel}
    else allowed
        Peer->>Governor: ShouldDefer(tier, critical)?
        alt RED/BLACKOUT and non-critical
            Governor-->>Peer: defer
            Peer-->>Op: 503 {deferred:true, tier}
        else run it
            Peer->>Peer: InFlight.TryAdd(id) → 409 if already running
            Peer->>Exec: ExecuteAsync(job) under job.TimeoutSec
            Exec-->>Peer: DispatchResult
            Peer->>Outbox: atomic write {id}.json
            Peer-->>Op: 200 DispatchResult (same request, synchronous)
        end
    end
    end

Both paths share the exact same verify order (schema → HMAC → job-id shape → kind → replay → target → cancel → availability gate → governor gate) and the exact same IJobExecutor — the only difference is transport (file vs. HTTP) and whether the caller waits in the same request.