Skip to content

docs: PLDM-FD-as-server IPC design (alternative to #458) - #464

Open
chrysh wants to merge 3 commits into
OpenPRoT:ocp-global-demo-wipfrom
9elements:docs-pldm-server-ipc
Open

chrysh wants to merge 3 commits into
OpenPRoT:ocp-global-demo-wipfrom
9elements:docs-pldm-server-ipc

Conversation

@chrysh

@chrysh chrysh commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Alternative to #458. PLDM-FD is the IPC server and owns the protocol state
machine; the orchestrator is its client and grants or denies each phase.
Firmware bytes never pass through the orchestrator.

  • Paired grant and deny ops at every gate, each deny carrying a reason:
    AcceptOffer/RejectOffer, GrantVerify, GrantApply, GrantActivate,
    GrantSvnCommit
  • All IPC is ServiceCall over channel_async_transact, with completion signals
    in a WaitGroup, so no process blocks on another and the FD never parks
  • Activation is pre-authorized at apply-complete, because FdOps::activate is
    synchronous and what it returns is the UA's completion code
  • Staging stops being writable before GrantVerify, so the crypto verdict
    describes the image apply commits
  • The SVN floor is not bumped at activation: the UA sends UpdateSecurityRevision
    (0x22) and the orchestrator advances the floor only on a confirmed trial

Full design: docs/src/design/orchestrator/pldm-server-ipc.md. Open questions
are listed at the end of that file.

In-transport sequence

sequenceDiagram
    participant UA as UA (BMC)<br/>remote, over MCTP
    participant FD as PLDM-FD (server)<br/>dispatch loop + run_terminus
    participant Orch as Orchestrator (client)<br/>ServiceCall
    participant DevSrv as Device Server<br/>manages the SPI flash
    participant Crypto as Crypto Service<br/>hash + signature verification

    Note over UA, Crypto: Blue background: orchestrator IPC. Green background: FdOps service IPC.

    Note over UA, Orch: NEGOTIATION (PLDM protocol, MCTP only)

    UA->>FD: RequestUpdate (MCTP)
    FD-->>UA: RequestUpdate response (accepted)
    UA->>FD: PassComponentTable (MCTP)
    FD-->>UA: PassComponentTable response
    UA->>FD: UpdateComponent (MCTP)
    FD-->>UA: UpdateComponent response

    Note over FD, Orch: FD has an offer, nudge the orchestrator

    FD->>Orch: USER signal (nudge: offer ready)

    rect rgb(230, 240, 255)
    activate Orch
    Orch->>FD: ServiceCall: QueryStatus
    FD-->>Orch: Status::OfferPending { target, total, mode: InTransport }
    Note right of Orch: validate target + total,<br/>platform driver picks<br/>staging address,<br/>reserve staging,<br/>open staging window
    Orch->>FD: ServiceCall: AcceptOffer { base: FlashAddress }
    FD-->>Orch: Ok
    deactivate Orch
    end

    Note over UA, DevSrv: TRANSFER (FD pulls from UA, FdOps writes to flash)

    rect rgb(230, 255, 230)
    loop FdOps::download_fw_data per chunk
        FD->>UA: RequestFirmwareData (MCTP)
        UA-->>FD: firmware chunk
        FD->>DevSrv: ServiceCall: write chunk
        DevSrv-->>FD: signal: Ok
    end
    end

    Note over FD, Orch: transfer done, ask orchestrator to grant verify

    FD->>UA: TransferComplete (MCTP)
    FD->>Orch: USER signal (nudge: verify pending)

    rect rgb(230, 240, 255)
    activate Orch
    Orch->>FD: ServiceCall: QueryStatus
    FD-->>Orch: Status::VerifyPending
    Note right of Orch: check isolation, update policy,<br/>close staging window
    Orch->>FD: ServiceCall: GrantVerify
    FD-->>Orch: Ok
    deactivate Orch
    end

    Note over FD, Crypto: FdOps::verify

    rect rgb(230, 255, 230)
    FD->>Crypto: ServiceCall::start(VerifyRequest { addr, size })
    Crypto->>DevSrv: read staged image
    DevSrv-->>Crypto: image data
    loop fd_progress poll
        Note over FD: verify() returns 0%, no signal yet
    end
    Crypto-->>FD: signal: Verdict
    Note over FD: verify() poll: try_recv -> 100% + verdict
    end
    FD->>UA: VerifyComplete (MCTP)

    Note over FD, Orch: verify done, ask orchestrator to grant apply

    FD->>Orch: USER signal (nudge: apply pending)

    rect rgb(230, 240, 255)
    activate Orch
    Orch->>FD: ServiceCall: QueryStatus
    FD-->>Orch: Status::ApplyPending
    Orch->>FD: ServiceCall: GrantApply
    FD-->>Orch: Ok
    deactivate Orch
    end

    Note over FD, DevSrv: FdOps::apply

    rect rgb(230, 255, 230)
    FD->>DevSrv: ServiceCall::start(apply: commit staged image)
    loop fd_progress poll
        Note over FD: apply() returns 0%, no signal yet
    end
    DevSrv-->>FD: signal: Ok
    Note over FD: apply() poll: try_recv -> 100%
    end
    FD->>UA: ApplyComplete (MCTP)

    Note over FD, Orch: apply done, decide activation before the UA asks

    FD->>Orch: USER signal (nudge: activation decision)

    rect rgb(230, 240, 255)
    activate Orch
    Orch->>FD: ServiceCall: QueryStatus
    FD-->>Orch: Status::ActivationPending
    Note right of Orch: check isolation, update policy
    Orch->>FD: ServiceCall: GrantActivate
    FD-->>Orch: Ok
    deactivate Orch
    end

    Note over UA, Orch: ACTIVATION (FdOps::activate, any time later)

    UA->>FD: ActivateFirmware (MCTP)
    FD-->>UA: ActivateFirmware response (accepted, from the stored grant)

    rect rgb(230, 255, 230)
    FD->>DevSrv: ServiceCall: FdOps::activate: set boot preference
    DevSrv-->>FD: signal: Ok
    end

    FD->>Orch: USER signal (nudge: activated)

    rect rgb(230, 240, 255)
    activate Orch
    Orch->>FD: ServiceCall: QueryStatus
    FD-->>Orch: Status::Idle { reason: ActivateFw }
    Note right of Orch: release staging,<br/>start judging the boot
    deactivate Orch
    end

    Note over UA, DevSrv: SVN COMMIT (later, FD back in IDLE, new image running)

    Note right of Orch: judge the boot, then<br/>TrialBoot::confirm or revert
    UA->>FD: UpdateSecurityRevision (MCTP, 0x22)
    FD->>Orch: USER signal (nudge: SVN commit requested)

    rect rgb(230, 240, 255)
    activate Orch
    Orch->>FD: ServiceCall: QueryStatus
    FD-->>Orch: Status::SvnCommitPending { component }
    Note right of Orch: a confirmed trial only,<br/>else DenySvnCommit
    Orch->>DevSrv: ServiceCall: SvnFloor::advance
    DevSrv-->>Orch: signal: Ok
    Orch->>FD: ServiceCall: GrantSvnCommit
    FD-->>Orch: Ok
    deactivate Orch
    end

    FD-->>UA: UpdateSecurityRevision response (success)

    Note over UA, Orch: CANCEL (between AcceptOffer and activation)
    UA->>FD: CancelUpdate (MCTP)
    rect rgb(230, 255, 230)
    FD->>DevSrv: ServiceCall: FdOps::cancel_update_component
    DevSrv-->>FD: signal: Ok
    end
    FD-->>UA: CancelUpdate response
    FD->>Orch: USER signal (nudge: cancelled)
    rect rgb(230, 240, 255)
    activate Orch
    Orch->>FD: ServiceCall: QueryStatus
    FD-->>Orch: Status::Cancelled
    Note right of Orch: release staging,<br/>close staging window
    Orch->>FD: ServiceCall: AckCancel
    FD-->>Orch: Ok
    deactivate Orch
    end
Loading

Out-of-transport sequence

sequenceDiagram
    participant UA as UA (BMC)<br/>remote, over MCTP
    participant FD as PLDM-FD (server)<br/>dispatch loop + run_terminus
    participant Orch as Orchestrator (client)<br/>ServiceCall
    participant DevSrv as Device Server<br/>manages the SPI flash
    participant Crypto as Crypto Service<br/>hash + signature verification

    Note over UA, Crypto: Blue background: orchestrator IPC. Green background: FdOps service IPC.

    Note over UA, Orch: NEGOTIATION (same as in-transport)

    UA->>FD: RequestUpdate (MCTP)
    FD-->>UA: RequestUpdate response (accepted)
    UA->>FD: PassComponentTable (MCTP)
    FD-->>UA: PassComponentTable response
    UA->>FD: UpdateComponent (MCTP, out-of-transport)
    FD-->>UA: UpdateComponent response

    Note over FD, Orch: FD has an offer, nudge the orchestrator

    FD->>Orch: USER signal (nudge: offer ready)

    rect rgb(230, 240, 255)
    activate Orch
    Orch->>FD: ServiceCall: QueryStatus
    FD-->>Orch: Status::OfferPending { target, total, mode: OutOfTransport }
    Note right of Orch: validate target + total,<br/>platform driver picks<br/>staging address
    Orch->>FD: ServiceCall: AcceptOffer { base: FlashAddress }
    Note left of FD: FD does not write in<br/>out-of-transport. A third party<br/>pre-stages the image, and how it<br/>learns the address is open
    FD-->>Orch: Ok
    deactivate Orch
    end

    Note over FD, Orch: no transfer phase, image already staged

    FD->>UA: TransferComplete (MCTP)

    Note over FD, Orch: ask orchestrator to grant verify

    FD->>Orch: USER signal (nudge: verify pending)

    rect rgb(230, 240, 255)
    activate Orch
    Orch->>FD: ServiceCall: QueryStatus
    FD-->>Orch: Status::VerifyPending
    Note right of Orch: check isolation, update policy,<br/>close staging window
    Orch->>FD: ServiceCall: GrantVerify
    FD-->>Orch: Ok
    deactivate Orch
    end

    Note over FD, Crypto: FdOps::verify

    rect rgb(230, 255, 230)
    FD->>Crypto: ServiceCall::start(VerifyRequest { addr, size })
    Crypto->>DevSrv: read staged image
    DevSrv-->>Crypto: image data
    loop fd_progress poll
        Note over FD: verify() returns 0%, no signal yet
    end
    Crypto-->>FD: signal: Verdict
    Note over FD: verify() poll: try_recv -> 100% + verdict
    end
    FD->>UA: VerifyComplete (MCTP)

    Note over FD, Orch: verify done, ask orchestrator to grant apply

    FD->>Orch: USER signal (nudge: apply pending)

    rect rgb(230, 240, 255)
    activate Orch
    Orch->>FD: ServiceCall: QueryStatus
    FD-->>Orch: Status::ApplyPending
    Orch->>FD: ServiceCall: GrantApply
    FD-->>Orch: Ok
    deactivate Orch
    end

    Note over FD, DevSrv: FdOps::apply

    rect rgb(230, 255, 230)
    FD->>DevSrv: ServiceCall::start(apply: commit staged image)
    loop fd_progress poll
        Note over FD: apply() returns 0%, no signal yet
    end
    DevSrv-->>FD: signal: Ok
    Note over FD: apply() poll: try_recv -> 100%
    end
    FD->>UA: ApplyComplete (MCTP)

    Note over FD, Orch: apply done, decide activation before the UA asks

    FD->>Orch: USER signal (nudge: activation decision)

    rect rgb(230, 240, 255)
    activate Orch
    Orch->>FD: ServiceCall: QueryStatus
    FD-->>Orch: Status::ActivationPending
    Note right of Orch: check isolation, update policy
    Orch->>FD: ServiceCall: GrantActivate
    FD-->>Orch: Ok
    deactivate Orch
    end

    Note over UA, Orch: ACTIVATION (same as in-transport)

    UA->>FD: ActivateFirmware (MCTP)
    FD-->>UA: ActivateFirmware response (accepted, from the stored grant)

    rect rgb(230, 255, 230)
    FD->>DevSrv: ServiceCall: FdOps::activate: set boot preference
    DevSrv-->>FD: signal: Ok
    end

    FD->>Orch: USER signal (nudge: activated)

    rect rgb(230, 240, 255)
    activate Orch
    Orch->>FD: ServiceCall: QueryStatus
    FD-->>Orch: Status::Idle { reason: ActivateFw }
    Note right of Orch: release staging,<br/>start judging the boot
    deactivate Orch
    end

    Note over UA, DevSrv: SVN COMMIT (later, FD back in IDLE, new image running)

    Note right of Orch: judge the boot, then<br/>TrialBoot::confirm or revert
    UA->>FD: UpdateSecurityRevision (MCTP, 0x22)
    FD->>Orch: USER signal (nudge: SVN commit requested)

    rect rgb(230, 240, 255)
    activate Orch
    Orch->>FD: ServiceCall: QueryStatus
    FD-->>Orch: Status::SvnCommitPending { component }
    Note right of Orch: a confirmed trial only,<br/>else DenySvnCommit
    Orch->>DevSrv: ServiceCall: SvnFloor::advance
    DevSrv-->>Orch: signal: Ok
    Orch->>FD: ServiceCall: GrantSvnCommit
    FD-->>Orch: Ok
    deactivate Orch
    end

    FD-->>UA: UpdateSecurityRevision response (success)

    Note over UA, Orch: CANCEL (between AcceptOffer and activation)
    UA->>FD: CancelUpdate (MCTP)
    rect rgb(230, 255, 230)
    FD->>DevSrv: ServiceCall: FdOps::cancel_update_component
    DevSrv-->>FD: signal: Ok
    end
    FD-->>UA: CancelUpdate response
    FD->>Orch: USER signal (nudge: cancelled)
    rect rgb(230, 240, 255)
    activate Orch
    Orch->>FD: ServiceCall: QueryStatus
    FD-->>Orch: Status::Cancelled
    Note right of Orch: discard the accepted offer
    Orch->>FD: ServiceCall: AckCancel
    FD-->>Orch: Ok
    deactivate Orch
    end
Loading

Assisted-by: Claude

@chrysh
chrysh force-pushed the docs-pldm-server-ipc branch 2 times, most recently from daf28d3 to bcdec55 Compare September 10, 2026 18:21
@chrysh chrysh changed the title docs: PLDM-as-server IPC design (alternative to #458) docs: PLDM-FD-as-server IPC design (alternative to #458) Sep 10, 2026
@chrysh
chrysh force-pushed the docs-pldm-server-ipc branch 6 times, most recently from e66be4b to 3843315 Compare September 11, 2026 10:18
Comment thread docs/src/design/orchestrator/pldm-server-ipc.md Outdated
@chrysh
chrysh marked this pull request as ready for review September 14, 2026 08:43
@chrysh chrysh mentioned this pull request Sep 14, 2026
2 tasks
@chrysh
chrysh force-pushed the docs-pldm-server-ipc branch 2 times, most recently from 4242d48 to f1b64a2 Compare September 14, 2026 18:59
@chrysh
chrysh force-pushed the docs-pldm-server-ipc branch from f1b64a2 to 493bd7d Compare September 14, 2026 20:21

Design decisions:

- ServiceCall is the universal IPC primitive. Every cross-process request

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth naming which pw_kernel syscalls start()/signal()/try_recv() map to — e.g. channel_async_transact + channel_async_transact_complete, with the initiator handle added to the caller's WaitGroup for signal().

One implication worth calling out here:
channel_async_transact is unsafe specifically because its send/recv buffers must stay "pinned" until the transaction completes, so ServiceCall<Req, Resp> can't hand start() transient stack buffers — it needs to own Req/Resp for the call's lifetime.

struct IpcPlatform {
// Pinned until try_complete() -- can't share one scratch buffer across
// in-flight channels; each gets its own fixed-size buffer, held here.
gpio_buf: (SendBuf, RecvBuf),
wdt_buf: (SendBuf, RecvBuf),
mailbox_buf: (SendBuf, RecvBuf),
power_buf: (SendBuf, RecvBuf),
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6bec3d4.

@chrysh
chrysh force-pushed the docs-pldm-server-ipc branch from 0840dbc to 583182b Compare September 16, 2026 09:41
@rusty1968

Copy link
Copy Markdown
Collaborator

@chrysh, I believe this is a more suitable design than the original proposal. If we are in agreement, given you stated that we should agree as a group to move on from that proposal, we can declare the orchestrator as a channel handler defunct and converge on this. Let's present this to the pigweed team so they can give us advice.

@chrysh

chrysh commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

@chrysh, I believe this is a more suitable design than the original proposal. If we are in agreement, given you stated that we should agree as a group to move on from that proposal, we can declare the orchestrator as a channel handler defunct and converge on this. Let's present this to the pigweed team so they can give us advice.

@rusty1968 sounds good. We also had a look together with @leongross at osfc2026. How do we present it to the pigweed people? Can you do that? And how can I help?

@rusty1968

Copy link
Copy Markdown
Collaborator

docs/src/design/orchestrator/pldm-server-ipc.md — channel busy-state error code and Unavailable semantics

pldm-server-ipc.md:52 states that a second start() on a busy channel returns ResourceExhausted. The kernel source disagrees: in pw_kernel/kernel/object/channel.rs, start_transaction() (channel.rs:263-302) returns Error::Unavailable at channel.rs:280 when active_transaction.is_some(). Both channel_transact() (channel.rs:206, blocking) and channel_async_transact() (channel.rs:226, async) call this same start_transaction(), so the error and the "one pending transaction per channel" rule apply identically to both.

Two implications for ServiceCall's error handling, since Error::Unavailable from this object is overloaded across three distinct call sites in pw_kernel/kernel/object/channel.rs:

  1. From start_transaction() (channel.rs:280): the channel already has a transaction in flight — the new call was rejected, nothing changed.
  2. From channel_async_transact_complete() (channel.rs:242): the transaction hasn't completed yet (still pending) — caller should poll again later.
  3. Also from channel_async_transact_complete() (channel.rs:242, same line): if the transaction was already cancelled via channel_async_cancel() (channel.rs:246), this same call also returns Unavailable — the transaction no longer exists.

ServiceCall::start()/try_recv() need to key off which of these three calls produced the Unavailable, not the error value alone, since the correct response differs (fail-fast vs. poll-again vs. transaction-gone).

Separately: because start_transaction() is shared, the "one pending transaction per channel" rule is per-channel, not per-API-mode. If an async transaction is outstanding and something calls blocking transact() on that same channel, transact() does not block waiting its turn — it fails immediately with Unavailable via the same start_transaction() path. So blocking and async calls can alternate sequentially on one channel, but a blocking call never queues behind an in-flight async one; any caller assuming it would block should expect an immediate error instead.

Alternative to OpenPRoT#458. The FD owns the dispatch loop and the PLDM state
machine; the orchestrator is its client and grants or denies each phase
(AcceptOffer, GrantVerify, GrantApply, GrantActivate, GrantSvnCommit). Firmware
bytes never pass through the orchestrator: FdOps::download_fw_data writes via
the device server and FdOps::verify delegates to the crypto service, which
reads the staged image from the device server itself.

Every cross-process request is a ServiceCall whose completion signal sits in
the caller's WaitGroup, so no process blocks on another and the orchestrator
stays reactive to CompromiseDetected and its boot watchdogs. The grant gates
add no PLDM states: pldm-lib polls verify and apply through fd_progress and
our implementation reports 0% until the grant arrives, which keeps the MCTP
responder live during the wait.

The security revision is not bumped at activation. Bumping there would put
the superseded image below the floor and make a trial-boot revert impossible,
so the UA sets Security Revision Number Delayed Update and sends
UpdateSecurityRevision (0x22) once it is satisfied with the running image.

Five crates, only the server-runtime and the client-ipc kernel-tagged, so the
wire and dispatch run host-side against a loopback. Write containment has two
layers, a device-server staging window and the SMC write filter. Both close
before GrantVerify, because a verdict over flash that can still be written does
not match the image that apply commits. Both cover staging only; apply and
activate stay reachable by a compromised FD and that gap is stated, not closed.
Open questions are listed at the end of the doc, FD death answered with
restart-and-nudge.

Assisted-by: Claude
Mermaid's sequence lexer ends message and note text at ";" (the TXT rule
is [^#\n;]), so the semicolon in the AcceptOffer note made the whole
second diagram fail to render.

Assisted-by: Claude (Anthropic)
The kernel returns Unavailable (not ResourceExhausted) when a second
start() hits a busy channel. Fold in the disambiguation: the same
Unavailable comes from try_recv() when still-pending or
already-cancelled, and a blocking transact() never queues behind an
in-flight async one.

Assisted-by: Claude

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants