#505 follow-up: the two-client completeness fold is unpinned, and legacyPrincipal can still re-open the escalation #509

Closed
opened 2026-09-12 05:36:12 +02:00 by ltms · 3 comments
Owner

Two items found while verifying PR #508 (merged). Neither blocked that merge — the production
behaviour is correct and #505's defect is pinned three ways. Both are gaps that let a future change
undo it silently.

1. The two-client completeness fold is not pinned by any test

PaneLocator.terminalForPid folds completeness across CB-185's two herdr clients:

for (int i = 0; i < herdrs.size(); i++) {
    Lookup outcome = scan(herdrs.get(i), i, herdrs.size(), ancestry);
    if (outcome.terminal() != null) {
        return outcome; // a definite match — no need to finish checking other clients
    }
    complete = complete && outcome.complete();
}

Measured. I mutated that fold to forget an earlier client's failure:

complete = complete && outcome.complete();      // original
complete = outcome.complete();                  // MUTANTC

Two greps with different search strings proved it applied (mutant present at :117, complete && outcome count 0).

baseline (original)  -> Tests run: 63, Failures: 0
MUTANTC              -> Tests run: 63, Failures: 0     <- NOT PINNED
restored             -> shasum 81b797a6… byte-identical, control 63/0

It is not an equivalent mutant. With two distinct daemons, a first client that scans incompletely
followed by a second that scans cleanly gives false originally and true mutated. That reports an
incomplete scan as complete, which promotes the caller — the exact escalation #505 closed, reachable
again through the multi-daemon path.

Harness proof that these cells can fail: re-running the PR's own mutation (UNKNOWN →
DOES_NOT_OWN) gave Tests run: 63, Failures: 3, naming the three tests meant to pin it. So the
63/0 above is a real zero, not a dead harness.

This is the #393 shape: no test assembles the combination where the mutation matters, because the
single-daemon tests collapse lead and member to one object (PaneLocatorTest even asserts
"same-object lead/member must scan exactly once, not twice"). The PR description claims this
behaviour in prose — "complete is the AND of both clients' completeness" — and nothing checks it.

This axis has cost us before. Four routing defects passed the whole suite when one client served
both roles because memberHerdrSocket was unset. Two herdr clients is a dimension that has to be
varied deliberately or it is not varied at all.

Asked for

A test with two distinct HerdrClient fakes where:

  • client 0's pane.process_info throws for some pane, and client 0 finds no match;
  • client 1 scans cleanly and also finds no match;
  • assert the overall Lookup.complete() is false, and that CallerResolver therefore answers
    anonymous.

Plus the companion that must stay green: client 0 errors on a pane it does not own, client 1 finds a
definite match → terminal is returned and the caller resolves normally. The short-circuit must
survive.

Mutation gate: apply MUTANTC above and confirm the new test goes red. If it still passes, the test
is not assembling the right configuration.

2. FleetMcp.legacyPrincipal drops scanComplete and would re-open the hole

private static Principal legacyPrincipal(ConnectionIdentity identity, String addr, int port) {
    ConnectionIdentity.Caller c = identity.resolve(addr, port);
    return c.terminal() != null
            ? Principal.worker(c.terminal(), c.pid())
            : Principal.primary(c.pid());
}

A failed scan leaves terminal == null, so this returns Principal.primary — #505 exactly, in a
second place.

Measured unreachable in production, so this is latent, not live. Fleetd.java:696 always passes
a real CallerResolver, and the contextExtractor only calls legacyPrincipal when
callers == null. A defect on paper is not a reachable defect.

But it is a loaded trap for whoever next touches that constructor, and ConnectionIdentity.callerTerminal()
is a second flag-dropping accessor with the same property.

The durable fix, and why it is not #415's antidote

This is the fleet01 lead's structural point and it is the right framing.

Authz.permits is already the default-less switch over Action that fleetd #415 recommends,
and that is the correct shape. It cannot help here: the switch is total over Action and says
nothing about whether caller was resolved correctly. Hand it a Principal that is
primary-by-error and every branch votes yes with the compiler satisfied. Principal carries Role,
terminal and name and no record of how it was resolved, so no downstream check could consult one
even if it wanted to.

#415 guards a decision nobody wrote. This is a decision written correctly and fed a bad input.
Making the decision total is orthogonal to making the input trustworthy.

So do not add a switch arm and do not add a Role. Make the unknown unrepresentable as a
principal
: a resolver result that cannot be turned into a Principal without the caller handling
the incomplete case. Then legacyPrincipal and callerTerminal either stop compiling or get
anonymous by construction, instead of each needing to remember a check.

Severity note, worth keeping

A detector whose failure mode grants authority is a different severity class from one whose
failure mode destroys state, because it does not need anything else to go wrong. detect_supervisor
returning none falls back toward kill; this falls back toward primary.

Related

  • #505 — the defect this follows up, merged in PR #508.
  • #317 — the other row of the same ternary.
  • #506 — the discriminator used here; item 1 is mechanism-adjacent but is really the #393 shape.
Two items found while verifying PR #508 (merged). Neither blocked that merge — the production behaviour is correct and #505's defect is pinned three ways. Both are gaps that let a future change undo it silently. ## 1. The two-client completeness fold is not pinned by any test `PaneLocator.terminalForPid` folds completeness across CB-185's two herdr clients: ```java for (int i = 0; i < herdrs.size(); i++) { Lookup outcome = scan(herdrs.get(i), i, herdrs.size(), ancestry); if (outcome.terminal() != null) { return outcome; // a definite match — no need to finish checking other clients } complete = complete && outcome.complete(); } ``` **Measured.** I mutated that fold to forget an earlier client's failure: ``` complete = complete && outcome.complete(); // original complete = outcome.complete(); // MUTANTC ``` Two greps with different search strings proved it applied (mutant present at `:117`, `complete && outcome` count 0). ``` baseline (original) -> Tests run: 63, Failures: 0 MUTANTC -> Tests run: 63, Failures: 0 <- NOT PINNED restored -> shasum 81b797a6… byte-identical, control 63/0 ``` **It is not an equivalent mutant.** With two distinct daemons, a first client that scans incompletely followed by a second that scans cleanly gives `false` originally and `true` mutated. That reports an incomplete scan as complete, which promotes the caller — the exact escalation #505 closed, reachable again through the multi-daemon path. **Harness proof that these cells can fail**: re-running the PR's own mutation (`UNKNOWN` → `DOES_NOT_OWN`) gave `Tests run: 63, Failures: 3`, naming the three tests meant to pin it. So the 63/0 above is a real zero, not a dead harness. This is the #393 shape: no test assembles the combination where the mutation matters, because the single-daemon tests collapse lead and member to one object (`PaneLocatorTest` even asserts "same-object lead/member must scan exactly once, not twice"). The PR description claims this behaviour in prose — "`complete` is the AND of both clients' completeness" — and nothing checks it. **This axis has cost us before.** Four routing defects passed the whole suite when one client served both roles because `memberHerdrSocket` was unset. Two herdr clients is a dimension that has to be varied deliberately or it is not varied at all. ### Asked for A test with **two distinct** `HerdrClient` fakes where: - client 0's `pane.process_info` throws for some pane, and client 0 finds no match; - client 1 scans cleanly and also finds no match; - assert the overall `Lookup.complete()` is **false**, and that `CallerResolver` therefore answers `anonymous`. Plus the companion that must stay green: client 0 errors on a pane it does not own, client 1 finds a definite match → `terminal` is returned and the caller resolves normally. The short-circuit must survive. Mutation gate: apply MUTANTC above and confirm the new test goes red. If it still passes, the test is not assembling the right configuration. ## 2. `FleetMcp.legacyPrincipal` drops `scanComplete` and would re-open the hole ```java private static Principal legacyPrincipal(ConnectionIdentity identity, String addr, int port) { ConnectionIdentity.Caller c = identity.resolve(addr, port); return c.terminal() != null ? Principal.worker(c.terminal(), c.pid()) : Principal.primary(c.pid()); } ``` A failed scan leaves `terminal == null`, so this returns `Principal.primary` — #505 exactly, in a second place. **Measured unreachable in production, so this is latent, not live.** `Fleetd.java:696` always passes a real `CallerResolver`, and the `contextExtractor` only calls `legacyPrincipal` when `callers == null`. A defect on paper is not a reachable defect. But it is a loaded trap for whoever next touches that constructor, and `ConnectionIdentity.callerTerminal()` is a second flag-dropping accessor with the same property. ### The durable fix, and why it is not #415's antidote This is the fleet01 lead's structural point and it is the right framing. `Authz.permits` is **already** the default-less `switch` over `Action` that fleetd #415 recommends, and that is the correct shape. It cannot help here: the switch is total over `Action` and says nothing about whether `caller` was resolved correctly. Hand it a `Principal` that is primary-by-error and every branch votes yes with the compiler satisfied. `Principal` carries `Role`, terminal and name and **no record of how it was resolved**, so no downstream check could consult one even if it wanted to. **#415 guards a decision nobody wrote. This is a decision written correctly and fed a bad input.** Making the decision total is orthogonal to making the input trustworthy. So do not add a switch arm and do not add a `Role`. Make the unknown **unrepresentable as a principal**: a resolver result that cannot be turned into a `Principal` without the caller handling the incomplete case. Then `legacyPrincipal` and `callerTerminal` either stop compiling or get `anonymous` by construction, instead of each needing to remember a check. ### Severity note, worth keeping A detector whose failure mode **grants authority** is a different severity class from one whose failure mode destroys state, because it does not need anything else to go wrong. `detect_supervisor` returning `none` falls back toward `kill`; this falls back toward `primary`. ## Related - #505 — the defect this follows up, merged in PR #508. - #317 — the other row of the same ternary. - #506 — the discriminator used here; item 1 is mechanism-adjacent but is really the #393 shape.
Author
Owner

Measured: this is a live bypass of #505's fix, not an independent gap

The fleet01 lead asked whether the two-client completeness fold is upstream of the predicate that #505's fix guards. If it is, then #505 is closed against one route while this one stays open. I traced the chain in the code just now, on main at aa4c0b8.

It is upstream. The chain is four links and there is no branch in it:

PaneLocator.java:117   complete = complete && outcome.complete();   <- the fold, my MUTANTC target
PaneLocator.java:119   return new Lookup(null, complete);
ConnectionIdentity.java:72   return new Caller(lookup.terminal(), pid, lookup.complete());
ConnectionIdentity.java:39   record Caller(String terminal, long pid, boolean scanComplete)
CallerResolver.java:254      return isLoopback(remoteAddr) && c.resolved() && c.scanComplete()
                                     ? Principal.primary(c.pid()) : Principal.anonymous();

Commands behind that, run on aa4c0b8:

grep -n 'complete' fleetd/src/main/java/dev/ltms/fleet/herdr/PaneLocator.java
grep -n 'scanComplete\|complete()' fleetd/src/main/java/dev/ltms/fleet/mcp/ConnectionIdentity.java
grep -rn '\.resolved()' fleetd/src --include='*.java'

So a wrong fold does not produce a wrong log line or a wrong metric. It produces a scanComplete of true for a scan that did not complete, and CallerResolver:254 — the line #505 fixed — then has no reason to refuse. The caller is promoted to Principal.primary. That is exactly the escalation #505 closed, reached from one link further up.

This raises the severity. A detector whose failure mode grants authority does not need anything else to go wrong. #509 was filed as a missing test; it is a missing test on the input side of a security gate.

Also measured: resolved() has exactly one production caller

The peer's other point was that resolved() is itself a sentinel conflating "we got a pid" with "we identified this caller", and that if it has several callers each one is another instance of the same escalation.

grep -rn '\.resolved()' fleetd/src --include='*.java'

Ten hits. One is production code: CallerResolver.java:254. Three are comments (CallerResolver:241, CallerResolver:249, PaneLocator:89), and the remaining six are test assertions and test comments in CallerResolverTest and ConnectionIdentityTest.

So with one production caller, splitting resolved() into two predicates is cosmetic today. The && c.scanComplete() guard at the single call site is sufficient. I am not widening this ticket to do that.

The caveat that keeps it worth writing down: it is sufficient because there is one caller. The name still claims more than the body checks (return pid > 0;), so a second caller written from the name alone gets the wrong answer. That is a rename, not a redesign, and it is not blocking.

What this does not change

The two work items stand as filed: a test that pins the two-client fold (my MUTANTC — complete = outcome.complete() — survives 63/0 today), and the legacyPrincipal hardening in FleetMcp.java:533-537. The fold test is now the more urgent of the two.

## Measured: this is a live bypass of #505's fix, not an independent gap The fleet01 lead asked whether the two-client completeness fold is upstream of the predicate that #505's fix guards. If it is, then #505 is closed against one route while this one stays open. I traced the chain in the code just now, on `main` at `aa4c0b8`. It is upstream. The chain is four links and there is no branch in it: ``` PaneLocator.java:117 complete = complete && outcome.complete(); <- the fold, my MUTANTC target PaneLocator.java:119 return new Lookup(null, complete); ConnectionIdentity.java:72 return new Caller(lookup.terminal(), pid, lookup.complete()); ConnectionIdentity.java:39 record Caller(String terminal, long pid, boolean scanComplete) CallerResolver.java:254 return isLoopback(remoteAddr) && c.resolved() && c.scanComplete() ? Principal.primary(c.pid()) : Principal.anonymous(); ``` Commands behind that, run on `aa4c0b8`: ``` grep -n 'complete' fleetd/src/main/java/dev/ltms/fleet/herdr/PaneLocator.java grep -n 'scanComplete\|complete()' fleetd/src/main/java/dev/ltms/fleet/mcp/ConnectionIdentity.java grep -rn '\.resolved()' fleetd/src --include='*.java' ``` So a wrong fold does not produce a wrong log line or a wrong metric. It produces a `scanComplete` of `true` for a scan that did not complete, and `CallerResolver:254` — the line #505 fixed — then has no reason to refuse. The caller is promoted to `Principal.primary`. That is exactly the escalation #505 closed, reached from one link further up. **This raises the severity.** A detector whose failure mode grants authority does not need anything else to go wrong. #509 was filed as a missing test; it is a missing test on the input side of a security gate. ## Also measured: `resolved()` has exactly one production caller The peer's other point was that `resolved()` is itself a sentinel conflating "we got a pid" with "we identified this caller", and that if it has several callers each one is another instance of the same escalation. ``` grep -rn '\.resolved()' fleetd/src --include='*.java' ``` Ten hits. One is production code: `CallerResolver.java:254`. Three are comments (`CallerResolver:241`, `CallerResolver:249`, `PaneLocator:89`), and the remaining six are test assertions and test comments in `CallerResolverTest` and `ConnectionIdentityTest`. So with one production caller, splitting `resolved()` into two predicates is cosmetic today. The `&& c.scanComplete()` guard at the single call site is sufficient. I am not widening this ticket to do that. The caveat that keeps it worth writing down: it is sufficient *because there is one caller*. The name still claims more than the body checks (`return pid > 0;`), so a second caller written from the name alone gets the wrong answer. That is a rename, not a redesign, and it is not blocking. ## What this does not change The two work items stand as filed: a test that pins the two-client fold (my MUTANTC — `complete = outcome.complete()` — survives 63/0 today), and the `legacyPrincipal` hardening in `FleetMcp.java:533-537`. The fold test is now the more urgent of the two.
ltms closed this issue 2026-09-12 06:07:06 +02:00
Author
Owner

Correction to the count in my comment above: it is 11 hits, not ten. Re-measured on main at
b37def9:

grep -rn '\.resolved()\|boolean resolved()' fleetd/src --include='*.java' | wc -l
11

My earlier pattern was '\.resolved()' only, which does not match the declaration
public boolean resolved() at ConnectionIdentity.java:60. Same tree, same file — the two numbers
differ because the patterns differ, not because anything changed.

The conclusion is unchanged. Still exactly one production call site, CallerResolver.java:254.
The 11 sort as: 1 declaration, 1 production call, 3 comments in src/main
(CallerResolver:241, CallerResolver:249, PaneLocator:89), 3 test assertions
(ConnectionIdentityTest:51, :59, :74), and 3 test comments (CallerResolverTest:133, :192,
ConnectionIdentityTest:49).

Worth recording rather than quietly fixing, because of what it shows: a bare count of a symbol is a
measurement of the regex, not of the callers.
Both of my runs happened to agree on the number that
mattered, but nothing in either count could tell me so — only reading and sorting all 11 lines could.
If the declaration had been a second call site instead, the narrower pattern would have hidden it and
the published count would have been wrong in the direction that mattered.

Correction to the count in my comment above: it is **11** hits, not ten. Re-measured on `main` at `b37def9`: ``` grep -rn '\.resolved()\|boolean resolved()' fleetd/src --include='*.java' | wc -l 11 ``` My earlier pattern was `'\.resolved()'` only, which does not match the declaration `public boolean resolved()` at `ConnectionIdentity.java:60`. Same tree, same file — the two numbers differ because the patterns differ, not because anything changed. **The conclusion is unchanged.** Still exactly one production call site, `CallerResolver.java:254`. The 11 sort as: 1 declaration, 1 production call, 3 comments in `src/main` (`CallerResolver:241`, `CallerResolver:249`, `PaneLocator:89`), 3 test assertions (`ConnectionIdentityTest:51`, `:59`, `:74`), and 3 test comments (`CallerResolverTest:133`, `:192`, `ConnectionIdentityTest:49`). Worth recording rather than quietly fixing, because of what it shows: **a bare count of a symbol is a measurement of the regex, not of the callers.** Both of my runs happened to agree on the number that mattered, but nothing in either count could tell me so — only reading and sorting all 11 lines could. If the declaration had been a second call site instead, the narrower pattern would have hidden it and the published count would have been wrong in the direction that mattered.
Author
Owner

Both items are delivered, and I have now measured the part that was only a claim

The fleet01 lead asked a question that decides whether #505 is really closed: is the two-client completeness fold upstream of the predicate the #505 fix guards? If yes, a bad fold hands the resolver a "complete" input, the resolver's guard sees no reason to refuse, and this ticket was a live bypass of #505 rather than an independent gap.

Answer: yes, it is directly upstream. Traced on main at a6415f3:

PaneLocator.java:117      complete = complete && outcome.complete();
        ↓
ConnectionIdentity.java:72  return new Caller(lookup.terminal(), pid, lookup.complete());
        ↓
CallerResolver.java:254     return isLoopback(remoteAddr) && c.resolved() && c.scanComplete()
                                    ? Principal.primary(c.pid()) : Principal.anonymous();

So the escalation route was real: lookup.complete() becomes Caller.scanComplete, which is the third conjunct of the promotion predicate. Had item 1 not been fixed, MUTANTC would have produced scanComplete == true from an incomplete scan and CallerResolver would have promoted, with the #505 guard fully in place and the suite green.

Item 1 is pinned — measured, not read

The test PaneLocatorTest.anEarlierClientsErrorSurvivesALaterClientsCleanNegative exists and matches this ticket's spec: two distinct HerdrClient fakes, the lead client errors on the pane that would have owned the pid and finds no match, the member client returns a clean negative, and the assertion is assertFalse(outcome.complete()).

Its comment claims it would catch MUTANTC. I measured that claim rather than trusting it, in a separate worktree at a6415f3:

Cell mvn test -Dtest=PaneLocatorTest
baseline, unmutated exit 0 — Tests run: 16, Failures: 0
MUTANTC (complete = outcome.complete();) exit 1 — Tests run: 16, Failures: 1
restored exit 0 — Tests run: 16, Failures: 0, file byte-identical 81b797a6cfb823e489e938b215956271d5741cce49dc9732f779c7ed64a3b811

The single failure names itself:

PaneLocatorTest.anEarlierClientsErrorSurvivesALaterClientsCleanNegative:205
an earlier client's error must survive a later client's clean negative ==> expected: <false> but was: <true>

The mutation was proven applied with two different search strings, each with a control against the pristine copy: MUTANTC fleetd509 → 1 mutated / 0 pristine; complete && outcome → 0 mutated / 1 pristine.

1 failure out of 16 is the substantive result. The new test is the only catcher. The other 15 hold the client-count axis fixed, exactly as this ticket argued — so coverage over PaneLocator was never the thing that was missing.

Item 2 is closed by deletion, not by a guard

FleetMcp.legacyPrincipal is gone. #524 deleted it outright rather than teaching it about scanComplete, and made the constructor take a required AuthorizationMode with no default, so the unsafe path cannot be reached by writing nothing. That is the stronger fix this ticket asked for — the unknown is unrepresentable because the branch no longer exists. Verified: 0 declarations, 0 calls, and the pre-#524 mutant no longer compiles (cannot find symbol: method legacyPrincipal(...)).

On closing a two-item ticket

fleet01's warning — "a ticket with two items is closed by the first one that produces a green run" — is the right thing to check here, and I checked it rather than assuming. Both items are genuinely done, by two separate merges (item 1's test, and #524 for item 2). The closure stands.

One thing this ticket's §"The durable fix" got slightly wrong in hindsight: it argued against #415's antidote on the grounds that Authz.permits is already a total switch fed a bad input. True for Authz, but #524 did apply #415's antidote one level up — at the constructor that chooses the resolver — and that is what actually removed the path. The antidote was applicable; it just belonged at the wiring seam, not at the decision.

## Both items are delivered, and I have now measured the part that was only a claim The fleet01 lead asked a question that decides whether #505 is really closed: **is the two-client completeness fold upstream of the predicate the #505 fix guards?** If yes, a bad fold hands the resolver a "complete" input, the resolver's guard sees no reason to refuse, and this ticket was a live bypass of #505 rather than an independent gap. **Answer: yes, it is directly upstream.** Traced on `main` at `a6415f3`: ``` PaneLocator.java:117 complete = complete && outcome.complete(); ↓ ConnectionIdentity.java:72 return new Caller(lookup.terminal(), pid, lookup.complete()); ↓ CallerResolver.java:254 return isLoopback(remoteAddr) && c.resolved() && c.scanComplete() ? Principal.primary(c.pid()) : Principal.anonymous(); ``` So the escalation route was real: `lookup.complete()` becomes `Caller.scanComplete`, which is the third conjunct of the promotion predicate. Had item 1 not been fixed, MUTANTC would have produced `scanComplete == true` from an incomplete scan and `CallerResolver` would have promoted, with the #505 guard fully in place and the suite green. ### Item 1 is pinned — measured, not read The test `PaneLocatorTest.anEarlierClientsErrorSurvivesALaterClientsCleanNegative` exists and matches this ticket's spec: two distinct `HerdrClient` fakes, the lead client errors on the pane that would have owned the pid and finds no match, the member client returns a clean negative, and the assertion is `assertFalse(outcome.complete())`. Its comment *claims* it would catch MUTANTC. I measured that claim rather than trusting it, in a separate worktree at `a6415f3`: | Cell | `mvn test -Dtest=PaneLocatorTest` | |---|---| | baseline, unmutated | exit 0 — `Tests run: 16, Failures: 0` | | MUTANTC (`complete = outcome.complete();`) | **exit 1 — `Tests run: 16, Failures: 1`** | | restored | exit 0 — `Tests run: 16, Failures: 0`, file byte-identical `81b797a6cfb823e489e938b215956271d5741cce49dc9732f779c7ed64a3b811` | The single failure names itself: ``` PaneLocatorTest.anEarlierClientsErrorSurvivesALaterClientsCleanNegative:205 an earlier client's error must survive a later client's clean negative ==> expected: <false> but was: <true> ``` The mutation was proven applied with two different search strings, each with a control against the pristine copy: `MUTANTC fleetd509` → 1 mutated / 0 pristine; `complete && outcome` → 0 mutated / 1 pristine. **1 failure out of 16 is the substantive result.** The new test is the only catcher. The other 15 hold the client-count axis fixed, exactly as this ticket argued — so coverage over `PaneLocator` was never the thing that was missing. ### Item 2 is closed by deletion, not by a guard `FleetMcp.legacyPrincipal` is gone. #524 deleted it outright rather than teaching it about `scanComplete`, and made the constructor take a required `AuthorizationMode` with no default, so the unsafe path cannot be reached by writing nothing. That is the stronger fix this ticket asked for — the unknown is unrepresentable because the branch no longer exists. Verified: 0 declarations, 0 calls, and the pre-#524 mutant no longer compiles (`cannot find symbol: method legacyPrincipal(...)`). ### On closing a two-item ticket fleet01's warning — "a ticket with two items is closed by the first one that produces a green run" — is the right thing to check here, and I checked it rather than assuming. Both items are genuinely done, by two separate merges (item 1's test, and #524 for item 2). The closure stands. One thing this ticket's §"The durable fix" got slightly wrong in hindsight: it argued against #415's antidote on the grounds that `Authz.permits` is already a total switch fed a bad input. True for `Authz`, but #524 *did* apply #415's antidote one level up — at the constructor that chooses the resolver — and that is what actually removed the path. The antidote was applicable; it just belonged at the wiring seam, not at the decision.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: fleet/fleetd#509